Vue 3 业务管理页面实战:组件拆分、父子通信与弹窗复用

在中后台系统中,一类非常常见的页面是"任务列表 + 条件筛选 + 新增编辑 + 明细弹窗 + 关联对象选择"。

这类需求看似简单,但如果页面逻辑都堆在一个 .vue 文件中,很快就会变成:状态难找、弹窗互相影响、接口调用散落、改一个功能牵动整页。本文以一个"任务管理 + 表具选择"的页面为例,整理一套 Vue 3 Composition API 的实践方式,重点讨论:

  • 如何按职责拆分业务组件;
  • CardHeader 与默认插槽的实际作用;
  • propsemitv-model:visible 如何实现父子组件联动;
  • 一个弹窗如何复用新增与编辑;
  • 列表如何在保存后自动刷新;
  • 如何让下拉数据预加载,避免首次打开弹窗卡顿;
  • 弹窗、抽屉、独立页面的选型边界。

本文不依赖某个特定 UI 库。示例中的 a-tablea-modal 可以理解为任意组件库的表格和弹窗组件。

一、先从页面职责开始拆分

假设我们要实现一个任务管理模块,包含两个页签:

  1. 任务页:查询、新增、编辑、查看详情、下发;
  2. 表具页:查询表具、多选、加入任务。

推荐的目录结构如下:

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
  ↓
弹窗关闭

五、@successupdate: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 固定事件,只是一个约定俗成的自定义事件名。也可以叫 savedcompletedrefresh,但应保证名称表达业务含义。

这种设计的好处是解耦:

  • 弹窗只负责表单校验和调用保存接口;
  • 列表页负责如何刷新数据;
  • 弹窗不需要知道父组件使用的是表格、卡片还是别的结构。

六、一个弹窗复用新增和编辑

新增、编辑通常字段相同,只是初始数据和请求方法不同。没有必要维护两个几乎一致的组件。

父组件:

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 访问和赋值要使用 .valuereactive 中的对象属性直接读写。

列表请求建议始终维护加载状态:

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>

这样有两个好处:

  1. 用户没有选择表具时,不会进入无意义弹窗;
  2. 子组件只收到需要提交的 meterCodes,无需理解整张表格。

十二、一个可复用的检查清单

完成一个"列表 + 弹窗"页面后,可以用下面清单自查:

组件职责

  • 列表查询、分页、选中状态是否只在页面组件中维护?
  • 新增/编辑是否复用同一个表单弹窗?
  • 明细展示是否独立为只读组件?
  • 接口调用是否集中在 API 文件?

父子通信

  • 弹窗是否通过 v-model:visible 由父组件控制?
  • 子组件是否避免直接修改 props?
  • 保存成功后是否 emit('success') 让父组件刷新?
  • 再次打开弹窗时,表单是否正确重置或回填?

交互体验

  • 按钮是否在无有效选中项时禁用?
  • 接口加载是否有 loading 状态?
  • 下拉选项是否需要预加载或远程搜索?
  • 空数据、重复数据、保存成功是否有反馈?

可维护性

  • 是否避免一个 .vue 文件同时承担列表、表单、详情和接口地址?
  • 文案是否通过国际化 key 管理?
  • 是否给复杂状态和关键事件起了容易理解的名字?

结语

Vue 3 组件化并不只是把页面拆成更多文件。真正的核心是明确状态归属:

  • 父组件拥有页面状态和业务编排;
  • 子组件拥有局部 UI 与一次性交互;
  • props 向下传数据;
  • emit 向上通知事件;
  • v-model 将"属性 + 更新事件"包装成清晰的双向交互入口。

当这套边界稳定下来,新增一个"任务列表 + 新增编辑弹窗 + 明细弹窗"的业务模块会变得非常可预测:先建 API,再写列表页状态,随后拆出弹窗,通过事件把刷新动作串起来即可。

相关推荐
雪芽蓝域zzs27 分钟前
Vue前端配置路由通配捕获 404
前端·javascript·vue.js
计算机魔术师28 分钟前
Anthropic 发布电商 Agent 架构与生产实践指南,并开源 commerce-agents 参考实现
前端
IMPYLH36 分钟前
HTML 的 <ol> 元素
前端·html
IMPYLH37 分钟前
HTML 的 <object> 元素
前端·html
DevOpenClub39 分钟前
PDF 多格式解析如何避免混用输出:TEXT、HTML、XML 与 TAG 数据契约
xml·前端·数据库·pdf·html
用户252632753471 小时前
请问同一台服务器能同时部署前后端项目并支持https吗?
前端
小凯在掘金1 小时前
类型判断什么时候用 typeof?
javascript
Sincerelyplz1 小时前
【Pipecat】基于Pipecat的voice agent实践
前端·后端·agent
lerhxx1 小时前
为什么你的Three.js物体总是乱转?一文彻底搞懂“万向锁”与四元数
前端·three.js