在中后台系统中,一类非常常见的页面是"任务列表 + 条件筛选 + 新增编辑 + 明细弹窗 + 关联对象选择"。
这类需求看似简单,但如果页面逻辑都堆在一个 .vue 文件中,很快就会变成:状态难找、弹窗互相影响、接口调用散落、改一个功能牵动整页。本文以一个"任务管理 + 表具选择"的页面为例,整理一套 Vue 3 Composition API 的实践方式,重点讨论:
- 如何按职责拆分业务组件;
CardHeader与默认插槽的实际作用;props、emit、v-model:visible如何实现父子组件联动;- 一个弹窗如何复用新增与编辑;
- 列表如何在保存后自动刷新;
- 如何让下拉数据预加载,避免首次打开弹窗卡顿;
- 弹窗、抽屉、独立页面的选型边界。
本文不依赖某个特定 UI 库。示例中的 a-table、a-modal 可以理解为任意组件库的表格和弹窗组件。
一、先从页面职责开始拆分
假设我们要实现一个任务管理模块,包含两个页签:
- 任务页:查询、新增、编辑、查看详情、下发;
- 表具页:查询表具、多选、加入任务。
推荐的目录结构如下:
text
api/
task.ts # 接口集中封装
views/taskManagement/
index.vue # 页签容器
task/
index.vue # 任务列表页
components/
TaskDialog.vue # 新增/编辑任务
TaskDetailDialog.vue # 任务明细
meter/
index.vue # 表具列表页
components/
AddToTaskDialog.vue # 多选表具后加入任务
拆分时,一个简单原则很有用:
页面负责"状态编排与数据列表",弹窗负责"单次交互与提交",API 文件负责"网络请求"。
因此:
- 列表页持有筛选条件、分页、表格数据、当前编辑对象;
- 弹窗只关心展示哪些字段、校验规则、保存动作;
- 弹窗不直接操作父页面的表格;
- 接口地址不散落在多个组件中。
这样做的收益是很直接的:新增字段时优先改弹窗;改查询条件时优先改列表页;改接口地址时只改 API 文件。
二、用 Card、CardHeader、CardContent 建立统一页面骨架
很多项目会把页面内容放在统一的卡片容器中:
vue
<Card>
<CardHeader>
<!-- 标题、说明、筛选、操作按钮 -->
</CardHeader>
<CardContent>
<!-- 表格、分页、统计等主体内容 -->
</CardContent>
</Card>
其中 CardHeader 本质上不是业务组件,它只是一个带统一样式的布局容器。例如:
vue
<template>
<div class="card-header">
<slot />
</div>
</template>
<style scoped>
.card-header {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 16px 16px;
}
</style>
它的价值在于统一:
- 标题区域的左右间距;
- 标题、说明、筛选区之间的垂直距离;
- 不同模块的视觉结构。
1. <slot /> 是什么?
<slot /> 是 Vue 的默认插槽。它表示组件预留一个内容位置,调用组件的人决定这个位置最终渲染什么。
调用方:
vue
<CardHeader>
<div class="header-row">
<div>
<CardTitle>任务管理</CardTitle>
<CardDescription>维护任务并查看执行进度</CardDescription>
</div>
<a-button type="primary">新增任务</a-button>
</div>
<div class="filters">
<a-input placeholder="任务名称" />
<a-button>查询</a-button>
</div>
</CardHeader>
渲染后,插槽中的内容会被放到 CardHeader 内部的 <slot /> 位置。可以把它理解为:
html
<div class="card-header">
<!-- 调用方写在 CardHeader 内的所有内容都在这里 -->
</div>
组件提供统一外壳,页面提供具体内容。这是组件复用最常见的方式。
2. 默认插槽和具名插槽
一个 <slot /> 是默认插槽。如果一个组件有多个固定区域,可以使用具名插槽:
vue
<!-- Panel.vue -->
<template>
<section>
<header><slot name="header" /></header>
<main><slot name="default" /></main>
<footer><slot name="footer" /></footer>
</section>
</template>
调用方式:
vue
<Panel>
<template #header>标题</template>
<div>主体内容</div>
<template #footer>底部操作</template>
</Panel>
对于普通的页面标题区,一个默认插槽通常足够;只有布局位置固定且明确分区时,才需要具名插槽。
三、父子组件通信的核心:数据向下,事件向上
Vue 组件通信最重要的一条原则是:
text
父组件 -- props --> 子组件
父组件 <-- emit --- 子组件
props:父组件向子组件传递数据;emit:子组件向父组件发送事件;- 子组件不应直接修改父组件传入的
props。
在 <script setup> 中,常见写法如下:
ts
const props = defineProps<{
visible: boolean;
task?: Task;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'success'): void;
}>();
这里需要注意:
defineProps返回的是只读的 props 对象;defineEmits返回的是一个函数;- 它们是 Vue 3
<script setup>提供的编译器宏,不需要额外导入。
四、v-model:visible 到底做了什么?
业务开发里经常看到:
vue
<TaskDialog v-model:visible="dialogVisible" />
它并不是一种神秘的双向数据绑定,而是以下写法的语法糖:
vue
<TaskDialog
:visible="dialogVisible"
@update:visible="dialogVisible = $event"
/>
父组件持有唯一的真实状态:
ts
const dialogVisible = ref(false);
打开弹窗:
ts
dialogVisible.value = true;
关闭弹窗时,子组件不能直接写:
ts
props.visible = false; // 不应该这样做
因为 visible 的所有权在父组件。子组件应该通知父组件:
ts
emit('update:visible', false);
1. 为什么子组件还要定义 visible?
子组件通常会做一层 computed 代理:
ts
const visible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value),
});
这不是维护第二份状态。
visible.value的读取,来自props.visible;visible.value = false的写入,变成emit('update:visible', false);- 真正保存状态的仍只有父组件的
dialogVisible。
于是子组件中可以更自然地写:
ts
visible.value = false;
完整过程:
text
子组件保存成功
↓
visible.value = false
↓
emit('update:visible', false)
↓
父组件 dialogVisible.value = false
↓
父组件重新传入 visible=false
↓
弹窗关闭
五、@success 与 update:visible 有什么区别?
两者都是子组件向父组件发事件,但语义不同。
vue
<TaskDialog
v-model:visible="dialogVisible"
:task="currentTask"
@success="loadTaskList"
/>
弹窗保存成功后:
ts
await TaskApi.save(form);
visible.value = false;
emit('success');
它表达了两个不同的事情:
| 事件 | 含义 | 父组件通常做什么 |
|---|---|---|
update:visible |
弹窗状态需要改变 | 更新 dialogVisible |
success |
业务保存成功 | 刷新列表、刷新统计、提示成功 |
success 不是 Vue 固定事件,只是一个约定俗成的自定义事件名。也可以叫 saved、completed、refresh,但应保证名称表达业务含义。
这种设计的好处是解耦:
- 弹窗只负责表单校验和调用保存接口;
- 列表页负责如何刷新数据;
- 弹窗不需要知道父组件使用的是表格、卡片还是别的结构。
六、一个弹窗复用新增和编辑
新增、编辑通常字段相同,只是初始数据和请求方法不同。没有必要维护两个几乎一致的组件。
父组件:
ts
const dialogVisible = ref(false);
const currentTask = ref<Task>();
const openCreate = () => {
currentTask.value = undefined;
dialogVisible.value = true;
};
const openEdit = (task: Task) => {
currentTask.value = task;
dialogVisible.value = true;
};
模板:
vue
<TaskDialog
v-model:visible="dialogVisible"
:task="currentTask"
@success="loadTaskList"
/>
子组件根据是否有 ID 判断模式:
ts
const taskId = computed(() => props.task?.id);
watch(
() => props.visible,
(show) => {
if (!show) return;
form.name = props.task?.name || '';
form.operatorId = props.task?.operatorId || '';
},
);
const submit = async () => {
await formRef.value.validate();
if (taskId.value) {
await TaskApi.update(taskId.value, form);
} else {
await TaskApi.create(form);
}
visible.value = false;
emit('success');
};
一个容易遗漏的细节:每次打开都要重置/回填
如果只在组件初始化时设置表单值,第一次新增后再打开编辑,很容易残留旧值。
因此推荐监听弹窗打开:
ts
watch(() => props.visible, (show) => {
if (!show) return;
Object.assign(form, {
name: props.task?.name || '',
operatorId: props.task?.operatorId || '',
});
});
这也是为什么"是否打开弹窗"不仅是 UI 状态,还是初始化表单的一个合适时机。
七、弹窗、抽屉与独立页面如何选择?
三者没有绝对优劣,核心是内容复杂度和用户是否需要保持上下文。
| 形式 | 适用场景 | URL |
|---|---|---|
| 弹窗 Modal | 字段少、操作短、保存后回到列表 | 通常不需要 |
| 抽屉 Drawer | 字段较多,仍希望保留列表上下文 | 通常不需要 |
| 独立页面 | 表单复杂、存在多步骤、需要刷新恢复和可分享链接 | 通常需要 |
1. 弹窗适合"小而完整"的操作
例如创建任务仅有"任务名称、执行人"两个字段,弹窗体验最好:
- 不离开列表;
- 保存后立刻看到新数据;
- 实现和维护成本低。
2. 抽屉适合"信息较多但仍属于列表上下文"的操作
例如编辑设备时需要展示基础信息、参数、历史记录、备注等。抽屉横向空间更充足,背后的列表仍可见。
3. 独立页面适合复杂流程
例如创建计划要选择多个对象、配置周期、预览规则、保存草稿、查看历史。此时应使用路由:
text
/tasks/create
/tasks/:id/edit
独立 URL 的价值包括:
- 浏览器刷新后还能恢复编辑对象;
- 前进、后退语义清晰;
- 页面可被复制链接、单独授权、单独收藏;
- 更适合复杂表单和多步骤流程。
八、列表页状态应该放在哪里?
通常放在列表页组件中:
ts
const keyword = ref('');
const loading = ref(false);
const tableData = ref<Task[]>([]);
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
});
经验上可以这样区分:
ts
const loading = ref(false); // 单个值:ref
const currentTask = ref<Task>(); // 单个对象引用:ref
const form = reactive({ // 多字段表单:reactive
name: '',
operatorId: '',
});
ref 访问和赋值要使用 .value;reactive 中的对象属性直接读写。
列表请求建议始终维护加载状态:
ts
const loadTaskList = async () => {
loading.value = true;
try {
const response = await TaskApi.list({
keyword: keyword.value || undefined,
pageIndex: pagination.current - 1,
pageSize: pagination.pageSize,
});
tableData.value = response?.result?.data || [];
pagination.total = Number(response?.result?.total || 0);
} finally {
loading.value = false;
}
};
finally 很重要:即使请求报错,加载动画也能正常结束。
九、接口统一放在 API 文件中
不要在多个 .vue 组件里散落字符串 URL:
ts
server.post('/task/_query', data);
server.post('/task');
server.put(`/task/${id}`);
推荐集中封装:
ts
// api/task.ts
import server from '@/utils/request';
export default {
list: (data: unknown) => server.post('/task/_query', data),
create: (data: unknown) => server.post('/task', data),
update: (id: string | number, data: unknown) =>
server.put(`/task/${id}`, data),
detail: (id: string | number) => server.get(`/task/${id}`),
};
页面中只关心业务调用:
ts
const response = await TaskApi.list(params);
好处:
- 改接口地址、请求方式或统一增加参数时只改一个位置;
- 页面更聚焦 UI 和交互;
- 容易 mock,也容易写接口测试。
十、下拉选项为什么建议预加载?
"选择任务后加入"这类弹窗,经常需要加载一个任务下拉列表。
一个常见但体验较差的写法是:弹窗打开后才请求:
ts
watch(() => props.visible, async (show) => {
if (!show) return;
options.value = await TaskApi.options();
});
它的问题是:用户第一次点击按钮时,弹窗已经出现,但下拉为空或处于加载状态。
更好的做法是在表具页进入时预加载:
ts
const taskOptions = ref<TaskOption[]>([]);
const taskOptionsLoading = ref(false);
const loadTaskOptions = async () => {
taskOptionsLoading.value = true;
try {
taskOptions.value = (await TaskApi.options())?.result || [];
} finally {
taskOptionsLoading.value = false;
}
};
onMounted(() => {
loadMeterList();
loadTaskOptions();
});
然后把缓存作为 props 交给弹窗:
vue
<AddToTaskDialog
v-model:visible="dialogVisible"
:task-options="taskOptions"
:loading="taskOptionsLoading"
/>
这类优化适用于:
- 数据量不大;
- 数据变化频率低;
- 用户很可能在稍后打开弹窗。
如果选项很多或变化很频繁,则应改为远程搜索、分页加载,而不是一次性预取。
十一、表具多选与"加入任务"的状态组织
表格多选状态一般由父页面维护:
ts
const selectedMeterCodes = ref<string[]>([]);
const rowSelection = computed(() => ({
selectedRowKeys: selectedMeterCodes.value,
onChange: (keys: Array<string | number>) => {
selectedMeterCodes.value = keys.map(String);
},
}));
按钮通过选中数量决定是否可用:
vue
<a-button
type="primary"
:disabled="!selectedMeterCodes.length"
@click="openAddToTask"
>
Add to Task List
</a-button>
这样有两个好处:
- 用户没有选择表具时,不会进入无意义弹窗;
- 子组件只收到需要提交的
meterCodes,无需理解整张表格。
十二、一个可复用的检查清单
完成一个"列表 + 弹窗"页面后,可以用下面清单自查:
组件职责
- 列表查询、分页、选中状态是否只在页面组件中维护?
- 新增/编辑是否复用同一个表单弹窗?
- 明细展示是否独立为只读组件?
- 接口调用是否集中在 API 文件?
父子通信
- 弹窗是否通过
v-model:visible由父组件控制? - 子组件是否避免直接修改 props?
- 保存成功后是否
emit('success')让父组件刷新? - 再次打开弹窗时,表单是否正确重置或回填?
交互体验
- 按钮是否在无有效选中项时禁用?
- 接口加载是否有
loading状态? - 下拉选项是否需要预加载或远程搜索?
- 空数据、重复数据、保存成功是否有反馈?
可维护性
- 是否避免一个
.vue文件同时承担列表、表单、详情和接口地址? - 文案是否通过国际化 key 管理?
- 是否给复杂状态和关键事件起了容易理解的名字?
结语
Vue 3 组件化并不只是把页面拆成更多文件。真正的核心是明确状态归属:
- 父组件拥有页面状态和业务编排;
- 子组件拥有局部 UI 与一次性交互;
- props 向下传数据;
- emit 向上通知事件;
v-model将"属性 + 更新事件"包装成清晰的双向交互入口。
当这套边界稳定下来,新增一个"任务列表 + 新增编辑弹窗 + 明细弹窗"的业务模块会变得非常可预测:先建 API,再写列表页状态,随后拆出弹窗,通过事件把刷新动作串起来即可。