Skip to content

Vue 组件封装 ​

理解路径 ​

封装一个组件,本质是设计一套稳定的对外接口,并隐藏内部实现。最常用的接口有三类:props(数据输入)、事件(行为输出)、插槽(结构注入);少数命令式场景还可以通过 defineExpose 暴露必要的公开方法。属性透传用于包装组件,composable 用于复用逻辑。

下面用中后台最高频的四个组件落地,每个突出一种封装范式:

  • 数据表格 Table:配置驱动 + 作用域插槽 + 受控分页
  • 弹窗 Modal:v-model 受控显隐 + 具名插槽
  • 查询表单 SearchForm:配置驱动渲染 + 整体 v-model 收集
  • 确认框 Dialog:命令式 / Promise 化调用

props / $emit / v-model / $attrs 的语法细节见 Vue 组件通信,本页聚焦如何把它们组织成可复用组件。

设计原则 ​

原则含义反面
单一职责一个组件只解决一类问题既管数据请求又管复杂展示
对外接口最小只暴露必要且稳定的 props / 事件 / 插槽 / 公开方法props 或公开方法不断膨胀,甚至暴露内部实现
单向数据流props 只读,状态变更靠事件回传子组件直接改 props
可组合优先用插槽 / composable 扩展,而非堆配置项用十几个布尔 props 控制结构
状态可预测明确受控 / 非受控,保持单一数据源父子各存一份、来回同步

如何理解“受控”和“非受控”? ​

受控 / 非受控描述的是某一项状态由谁持有和最终修改,而不是 Vue 提供的两种组件类型。 一个组件也不必整体只能属于其中一种:例如 Modal 的显隐状态可以受控,而过渡动画是否正在执行仍可由组件内部管理。

  • 受控状态:唯一数据源在组件外部,通常由父组件持有。父组件通过 prop 或 v-model 传入当前值;子组件发生交互时只发出更新意图,父组件接收事件、修改状态,再把新值传回子组件。所以下面的 Table 页码和 Modal 显隐都能由父组件随时读取、修改、重置或与其他组件协调。
  • 非受控状态:唯一数据源在组件内部,通常由 ref / reactive 持有。父组件可以传入 defaultXxx 作为初始值,但后续交互由子组件直接修改自己的状态;即使子组件向外发送 change 等通知事件,状态所有权也没有因此转移给父组件。
判断维度受控状态非受控状态
当前值存在哪里父组件等外部调用方子组件内部
父组件传入什么持续传入当前值最多传入一次初始值
用户交互后谁写入新值子组件发事件,父组件决定并写入子组件直接写入内部状态
父组件能否随时改值可以,新 prop 会成为界面状态通常不可以,只能重新创建组件或调用额外接口
Vue 常见契约modelValue + update:modelValue,或 defineModel()defaultValue + 内部响应式状态
适合场景状态要被读取、同步、持久化或跨组件协调仅影响组件自身、外部不关心过程状态

判断时可以只问一句:如果父组件现在把值改掉,子组件是否必须立即以这个值为准? 是,就是受控;如果父组件只给初始值,之后由子组件自己演进,就是非受控。

需要注意以下边界:

  • v-model 只是受控契约的简写。Vue 3.4+ 的 defineModel() 底层仍是一个 prop 和对应的 update:* 事件;子组件修改 model ref,本质是在通知父组件更新。
  • “发出了事件”不等于受控。非受控组件也可以发事件,让外部得知变化,但当前值仍由内部保存。
  • 不要把受控 prop 再复制到内部状态并用 watch 双向同步,否则父子会各有一份数据源。需要临时草稿时,应明确区分“外部已提交值”和“内部编辑草稿”。
  • 可复用基础组件确实需要同时支持两种用法时,通常分别提供 modelValue 和 defaultValue;同一个组件实例创建后不要在受控与非受控之间切换。

数据表格 Table ​

中后台最高频的封装,考点集中在业务类型推导、泛型协议、配置驱动、作用域插槽和受控分页。

设计参考与取舍

本例参考了这篇关于 TypeScript 业务代码的讨论提出的“TypeScript 是代码的附庸”观点,但没有把其中的规则绝对化:

  • 采纳推导优先:本地业务数据先定义,User 再由 typeof 推导,避免数据和 interface 重复维护;函数返回值交给 TypeScript 推导。
  • 采纳运行时边界:如果数据来自 API,应该先用 Schema 校验未知输入,再从 Schema 推导类型;不能只写一个 interface 就假定外部数据可信。本例的数据写在本地,因此不额外引入校验库。
  • 采纳“类型暴露抽象成本”:列配置一旦与行数据失去类型联系,既说明类型设计有问题,也说明运行时代码可能把“操作列”误当成真实字段。
  • 保留公共协议先行:BaseTable 不是普通业务数据,而是制定调用协议的基础组件,所以仍需提前定义泛型契约。业务类型跟随数据,工具类型约束边界,两者并不冲突。
  • 不把语法风格当成铁律:函数声明或箭头函数根据语义、作用域和团队规范选择;响应式状态也不能替代 props、事件和插槽这些公开组件契约。

下面先把五个概念与代码位置对应起来。后续三个源码文件使用相同编号标注,便于从概念直接定位到实现:

编号概念主要代码位置解决的问题
①业务类型推导allUsers、typeof allUsers让业务类型跟随真实数据,避免重复维护 interface
②泛型协议tableTypes.ts、generic="Row extends object"、TableColumn<Row>连接行数据、列、行键和插槽,同时不写死业务字段
③配置驱动columns、根据 columns 执行的 v-for通过修改配置增删列,不修改表格结构
④作用域插槽defineSlots、子组件 <slot>、父组件 #cell-*表格提供行数据,业务侧决定特殊单元格如何渲染
⑤受控分页父组件 page 和 v-model:page、子组件 update:page页码只由父组件保存,子组件只通知翻页意图

原实现的 key: string 与 Record<string, any>[] 会切断列和行之间的关系。重构后先把公共协议集中到一个小文件中:

ts
// tableTypes.ts

/**
 * ② 泛型协议
 *
 * 设计参考:
 * https://www.zhihu.com/question/2064308201680527502/answer/2066128798328923064?utm_psn=2066334603263096244
 *
 * 取舍:业务行类型应尽量从真实数据或运行时 Schema 推导;但 BaseTable
 * 是负责制定公共调用协议的基础组件,因此需要显式声明泛型契约。
 * 这里的类型只描述“行、列、行键、插槽”之间的关系,不重复业务字段。
 */

/** 只保留对象中的字符串字段名,供数据列使用。 */
export type StringKeyOf<Row extends object> = Extract<keyof Row, string>

/** 找出值可以安全充当 Vue :key 的字段。 */
export type PropertyKeyFieldOf<Row extends object> = {
  [Key in StringKeyOf<Row>]-?: Row[Key] extends PropertyKey ? Key : never
}[StringKeyOf<Row>]

/** 数据列必须指向 Row 上真实存在的字段。 */
export interface FieldColumn<Row extends object> {
  kind: 'field'
  field: StringKeyOf<Row>
  title: string
}

/** 操作按钮等内容不是 Row 的字段,因此单独建模为自定义列。 */
export interface CustomColumn {
  kind: 'custom'
  id: string
  title: string
}

export type TableColumn<Row extends object> =
  | FieldColumn<Row>
  | CustomColumn

/** 行键可以是 Row 中值为 PropertyKey 的字段,也可以是计算函数。 */
export type TableRowKey<Row extends object> =
  | PropertyKeyFieldOf<Row>
  | ((row: Row) => PropertyKey)

/** 所有单元格插槽都会获得完整 Row;数据列额外获得 value。 */
export interface TableSlotProps<Row extends object> {
  row: Row
  column: TableColumn<Row>
  value: Row[StringKeyOf<Row>] | undefined
}

export type TableSlots<Row extends object> = {
  [name: string]: (props: TableSlotProps<Row>) => unknown
}

BaseTable 用 Row 泛型把 data、columns、rowKey 和插槽参数连接起来。组件只执行通用行为,不声明任何 User 业务字段:

vue
<!-- BaseTable.vue -->
<!-- ② 泛型协议:Row 把数据、列、行键和插槽连接成同一套契约。 -->
<script setup lang="ts" generic="Row extends object">
import { computed } from 'vue'
import type {
  TableColumn,
  TableRowKey,
  TableSlots
} from './tableTypes'

interface Props {
  /** 列配置必须与当前 Row 保持类型关联。 */
  columns: readonly TableColumn<Row>[]
  /** 只读取数据,不在表格内部修改业务行。 */
  data: readonly Row[]
  /** 显式声明行键,避免把 id 写死在通用组件里。 */
  rowKey: TableRowKey<Row>
  loading?: boolean
  /** 受控分页:当前页由父组件持有,因此 page 是必传项。 */
  page: number
  pageSize?: number
  total?: number
}

const props = withDefaults(defineProps<Props>(), {
  loading: false,
  pageSize: 10,
  total: 0
})

// ④ 作用域插槽:声明父组件能够接收的插槽参数和具体 Row 类型。
defineSlots<TableSlots<Row>>()

const emit = defineEmits<{
  /** ⑤ 受控分页:配合 v-model:page,只表达翻页意图,不私自保存页码。 */
  'update:page': [page: number]
}>()

const pageCount = computed(() =>
  Math.max(1, Math.ceil(props.total / props.pageSize))
)

/** 同时支持字段行键和计算函数行键。 */
function getRowKey(row: Row): PropertyKey {
  if (typeof props.rowKey === 'function') return props.rowKey(row)
  return row[props.rowKey] as PropertyKey
}

/** 数据列和自定义列使用不同前缀,防止同名时 key 冲突。 */
function getColumnKey(column: TableColumn<Row>): string {
  return column.kind === 'field'
    ? `field:${String(column.field)}`
    : `custom:${column.id}`
}

/** 数据列按真实字段取值;自定义列没有虚假的默认值。 */
function getCellValue(
  row: Row,
  column: TableColumn<Row>
): Row[keyof Row] | undefined {
  return column.kind === 'field' ? row[column.field] : undefined
}

function getSlotName(column: TableColumn<Row>): string {
  const id = column.kind === 'field' ? String(column.field) : column.id
  return `cell-${id}`
}

/** 翻页前统一校验边界,再把合法的新页码交给父组件。 */
function goToPage(target: number) {
  if (target < 1 || target > pageCount.value) return
  emit('update:page', target)
}
</script>

<template>
  <div class="base-table">
    <table class="base-table__table">
      <thead>
        <tr>
          <th v-for="column in columns" :key="getColumnKey(column)">
            {{ column.title }}
          </th>
        </tr>
      </thead>
      <tbody>
        <!-- ③ 配置驱动:同一份 columns 决定每一行渲染哪些单元格。 -->
        <tr v-for="row in data" :key="getRowKey(row)">
          <td v-for="column in columns" :key="getColumnKey(column)">
            <!--
              ④ 作用域插槽:把 row、column、value 交给父组件。
              数据列默认显示真实字段值;同名插槽可以覆盖默认展示。
              自定义列没有字段值,必须由 cell-{id} 插槽提供内容。
            -->
            <slot
              :name="getSlotName(column)"
              :row="row"
              :column="column"
              :value="getCellValue(row, column)"
            >
              <template v-if="column.kind === 'field'">
                {{ getCellValue(row, column) }}
              </template>
            </slot>
          </td>
        </tr>
      </tbody>
    </table>

    <p v-if="loading" class="base-table__status">加载中…</p>
    <p v-else-if="!data.length" class="base-table__status">暂无数据</p>

    <!-- ⑤ 受控分页:按钮只调用 goToPage,最终通过 update:page 通知父组件。 -->
    <div class="base-table__pager">
      <button
        type="button"
        :disabled="page <= 1"
        @click="goToPage(page - 1)"
      >
        上一页
      </button>
      <span>{{ page }} / {{ pageCount }}</span>
      <button
        type="button"
        :disabled="page >= pageCount"
        @click="goToPage(page + 1)"
      >
        下一页
      </button>
    </div>
  </div>
</template>

业务侧先产生数据,再推导 User。satisfies 只检查列配置是否符合 TableColumn<User>,不会把原本精确的字面量类型强行改宽:

vue
<!-- TableApp.vue -->
<script setup lang="ts">
import { computed, shallowRef } from 'vue'
import BaseTable from './BaseTable.vue'
import type { TableColumn } from './tableTypes'

// ① 业务类型推导:先声明真实业务数据,而不是先手写 User interface。
const allUsers = [
  { id: 1, name: '张三', role: '管理员', status: 'active' },
  { id: 2, name: '李四', role: '编辑', status: 'active' },
  { id: 3, name: '王五', role: '访客', status: 'inactive' },
  { id: 4, name: '赵六', role: '编辑', status: 'active' },
  { id: 5, name: '钱七', role: '访客', status: 'inactive' },
  { id: 6, name: '孙八', role: '管理员', status: 'active' }
] as const

// ① 业务类型推导:User 跟随数据变化,增删字段时不必同步修改 interface。
type User = (typeof allUsers)[number]

// ③ 配置驱动:列结构集中在 columns;增删普通列不需要修改 BaseTable。
const columns = [
  // field 只能填写 User 中真实存在的字段。
  { kind: 'field', field: 'name', title: '姓名' },
  { kind: 'field', field: 'role', title: '角色' },
  { kind: 'field', field: 'status', title: '状态' },
  // 操作列不是 User 字段,必须显式声明为 custom。
  { kind: 'custom', id: 'action', title: '操作' }
] as const satisfies readonly TableColumn<User>[]

// ⑤ 受控分页:父组件持有唯一页码,并据此派生当前页数据。
const page = shallowRef(1)
const pageSize = 3

// 当前页数据完全由 allUsers、page 和 pageSize 派生,不重复保存。
const list = computed(() =>
  allUsers.slice((page.value - 1) * pageSize, page.value * pageSize)
)

const lastAction = shallowRef('')

function edit(row: User) {
  lastAction.value = `编辑:${row.name}`
}

function remove(row: User) {
  lastAction.value = `删除:${row.name}`
}
</script>

<template>
  <BaseTable
    v-model:page="page"
    :columns="columns"
    :data="list"
    :page-size="pageSize"
    :total="allUsers.length"
    row-key="id"
  >
    <!-- ④ 作用域插槽:父组件接收 row,自定义状态列的展示。 -->
    <template #cell-status="{ row }">
      <span :class="`tag--${row.status}`">
        {{ row.status === 'active' ? '启用' : '停用' }}
      </span>
    </template>

    <!-- ④ 作用域插槽:自定义列接收 row,不再尝试读取 row.action。 -->
    <template #cell-action="{ row }">
      <button type="button" @click="edit(row)">编辑</button>
      <button type="button" @click="remove(row)">删除</button>
    </template>
  </BaseTable>

  <p v-if="lastAction">最近操作:{{ lastAction }}</p>
</template>

封装结果与边界

  • 业务类型跟随事实:本地固定数据用 typeof 推导;真实 API 数据应由运行时 Schema 校验并推导,不能照搬这里的 as const。
  • 公共协议显式建模:泛型 Table 需要提前说明行、列、行键和插槽的关系,这是基础组件承担的协议成本,不是重复业务类型。
  • 数据列 / 自定义列分离:数据列只能读取 Row 的真实字段;状态、按钮、图片等自定义内容使用有明确 id 的插槽列。
  • 分页保持受控:page 必传,组件只发送合法的新页码,数据请求和当前页仍由父组件负责。
  • 保留适度复杂度:动态插槽能准确传递完整 Row,但 value 只能保守地表示为所有字段值的联合类型。若要求每个插槽的 value 都与字段逐一对应,就要引入更复杂的列 DSL 或渲染函数;本例选择让 row 保持精确,避免类型设计反过来淹没组件逻辑。

可编辑的完整沙盒(Monaco 编辑器,浏览器内实时编译)——左侧切换 TableApp.vue / BaseTable.vue / tableTypes.ts,右侧实时预览:

弹窗 Modal ​

弹窗的核心是受控显隐和具名插槽分区。

vue
<!-- BaseModal.vue -->
<script setup lang="ts">
defineProps<{ title?: string }>()

// v-model:visible 双向绑定,父组件是显隐状态的唯一来源
const visible = defineModel<boolean>('visible', { default: false })
const emit = defineEmits<{ close: [] }>()

function close() {
  visible.value = false
  emit('close')
}
</script>

<template>
  <Teleport to="body">
    <div v-if="visible" class="modal__mask" @click.self="close">
      <div class="modal" role="dialog" aria-modal="true">
        <header class="modal__head">
          <slot name="header">
            <h3>{{ title }}</h3>
          </slot>
          <button aria-label="关闭" @click="close">×</button>
        </header>

        <div class="modal__body">
          <slot />
        </div>

        <footer v-if="$slots.footer" class="modal__foot">
          <slot name="footer" :close="close" />
        </footer>
      </div>
    </div>
  </Teleport>
</template>
vue
<BaseModal v-model:visible="open" title="新建用户">
  <UserForm v-model="form" />
  <template #footer="{ close }">
    <button @click="close">取消</button>
    <button @click="submit">保存</button>
  </template>
</BaseModal>

封装要点

  • 受控显隐:用 defineModel('visible') 双向绑定,组件内部不再另存一份 open,避免父子两份状态不同步。
  • 具名插槽分区:header / 默认 / footer 三个槽位;footer 通过作用域把 close 暴露给调用方,按钮逻辑交还业务。
  • 插槽判空:没传 footer 就不渲染底栏(v-if="$slots.footer"),避免空白边距。
  • 可访问性:role="dialog" + aria-modal,遮罩 @click.self 与关闭按钮两种关闭方式。

可编辑的完整沙盒(Monaco 编辑器,浏览器内实时编译)——左侧切换 ModalApp.vue / BaseModal.vue,右侧实时预览:

查询表单 SearchForm ​

列表页标配,考点是配置驱动渲染和整体 v-model 收集。

vue
<!-- SearchForm.vue -->
<script setup lang="ts">
interface Field {
  key: string
  label: string
  type: 'input' | 'select'
  options?: { label: string; value: string }[]
}

defineProps<{ fields: Field[] }>()

// 整个查询对象用一个 v-model 收集
const model = defineModel<Record<string, any>>({ default: () => ({}) })
const emit = defineEmits<{ search: []; reset: [] }>()

function reset() {
  for (const key of Object.keys(model.value)) {
    model.value[key] = ''
  }
  emit('reset')
}
</script>

<template>
  <form class="search-form" @submit.prevent="emit('search')">
    <label v-for="field in fields" :key="field.key" class="search-form__item">
      <span>{{ field.label }}</span>
      <select v-if="field.type === 'select'" v-model="model[field.key]">
        <option v-for="opt in field.options" :key="opt.value" :value="opt.value">
          {{ opt.label }}
        </option>
      </select>
      <input v-else v-model="model[field.key]" />
    </label>

    <button type="submit">查询</button>
    <button type="button" @click="reset">重置</button>
  </form>
</template>
vue
<SearchForm
  v-model="query"
  :fields="[
    { key: 'name', label: '姓名', type: 'input' },
    { key: 'status', label: '状态', type: 'select', options: statusOptions }
  ]"
  @search="fetchList"
  @reset="fetchList"
/>

封装要点

  • 配置驱动渲染:表单项由 fields 描述,组件按 type 选控件,新增查询条件只改配置不动组件。
  • 整体 v-model 收集:用一个 defineModel 把所有字段聚成查询对象,父组件直接拿 query 发请求。
  • 行为事件化:组件只收集数据并发出 search / reset,请求逻辑留在父层,跨页面复用。

可编辑的完整沙盒(Monaco 编辑器,浏览器内实时编译)——左侧切换 SearchFormApp.vue / SearchForm.vue,右侧实时预览:

确认框 Dialog ​

确认 / 提示类对话框与 Modal 的关键区别在调用方式:Modal 挂在模板里用 v-model 控制;确认框更适合命令式 / Promise 化——await 一句话拿到用户选择。

ts
// useConfirm.ts —— 命令式确认框
import { createApp } from 'vue'
import ConfirmDialog from './ConfirmDialog.vue'

export function confirm(message: string, title = '提示'): Promise<boolean> {
  return new Promise((resolve) => {
    const host = document.createElement('div')
    document.body.appendChild(host)

    const app = createApp(ConfirmDialog, {
      message,
      title,
      onResolve(result: boolean) {
        resolve(result)
        app.unmount()   // 关闭即销毁,自动清理挂载节点
        host.remove()
      }
    })
    app.mount(host)
  })
}
ts
async function remove(row: Row) {
  const ok = await confirm(`确定删除「${row.name}」?`)
  if (!ok) return
  await api.delete(row.id)
}

封装要点

  • 命令式服务 vs 声明式组件:Modal 在模板中声明,并用 v-model 让父组件控制显隐;确认框做成函数式服务后,await confirm(...) 一行拿到结果,临时显隐状态由服务内部管理,不必在每个页面声明 visible 和回调。命令式 / 声明式描述调用方式,受控 / 非受控描述状态所有权,两组概念不能直接画等号。
  • Promise 化:把“点确定 / 取消”包成 Promise<boolean>,业务用 async/await 顺序书写,避免回调嵌套。
  • 动态挂载 + 自动清理:用时 createApp 挂到 body,关闭后 unmount 并移除宿主节点,无副作用残留。
  • 也可做成组件 + defineExpose({ open }),但需在模板里放实例;全局随处可调的确认 / 提示更适合命令式服务。

可编辑的完整沙盒(Monaco 编辑器,浏览器内实时编译)——左侧切换 DialogApp.vue / ConfirmDialog.vue / useConfirm.ts 三个文件,右侧实时预览:


封装检查清单 ​

维度检查点
职责单一?过大就拆子组件 + composable
Props类型化契约 + 默认值;对象默认值用工厂函数;props 只读
配置驱动列 / 字段等可变结构用配置(columns / fields),不写死业务
事件显式 defineEmits + 类型化 payload;行为事件化,请求留父层
状态先明确受控 / 非受控;受控契约可用 defineModel,不要在父子两处复制同一状态
插槽作用域插槽把数据交还父组件自定义;可选插槽 $slots 判空
命令式确认 / 提示用 Promise 化函数式调用;组件命令式入口 defineExpose 最小暴露
透传二次封装 inheritAttrs: false + v-bind="$attrs"(见组件通信)
健壮性加载 / 空状态内置;副作用卸载清理;a11y(语义标签 + aria + 键盘)

参考来源 ​