Appearance
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 + 键盘) |