组件设计与数据流

Vue 组件设计与数据流

快速上手篇里 props/emits 已经见过一面, 这篇把组件这件事真正想明白: 什么时候拆、怎么定边界、父子和跨层之间数据怎么流。我的体感是, Vue 语法学完之后, 真实项目里 80% 的纠结都发生在这一层, 就像 Java 语法都会但类怎么设计才是真功夫。

1. 组件是什么, 什么时候拆

组件就是前端的”类”: 把一块界面的结构、样式、逻辑封装成一个可复用单元, 对外只暴露参数 (props) 和事件 (emits)。页面则是组件拼出来的。

什么时候拆? 我用三个问题判断: 这块会不会在别处复用 (复用性); 它是不是一个能独立说清楚的职责, 比如”筛选栏""编辑弹窗” (单一职责); 拆出去后接口是不是清爽, 传三个 props 出两个事件就能用 (低耦合)。
全是”是”就拆。反过来, 拆组件的目的从来都只是”500 行拆成 5 个 100 行文件”这种物理搬运的话, 拆了也白拆。

还有一条铁律叫单向数据流: 状态归父组件持有, 子组件只负责展示和”报告发生了什么”, 从不亲手改父组件的数据。数据只有一个主人, 出了错才知道去哪查。

2. Props 与 Emits: 父子之间的契约

先明确谁是父谁是子: A 组件 import 了 B 并写在自己模板里, A 就是父。父往下传数据用 props, 子往上报事件用 emits:

代码块VUE · 36 行收起展开
<!-- TaskItem.vue (子) -->
<script setup lang="ts">
// interface 定义数据形状 (TS 语法, 相当于轻量的 DTO 类)
interface Task {
  id: number
  title: string
  done: boolean
}

// withDefaults 给可选 prop 设默认值
const props = withDefaults(defineProps<{
  task: Task
  readonly?: boolean       // ? 表示可传可不传
}>(), {
  readonly: false
})

// 声明本组件会发出的事件和参数类型
const emit = defineEmits<{
  toggle: [id: number, done: boolean]
  remove: [id: number]
}>()
</script>

<template>
  <article>
    <input
      :checked="task.done"
      :disabled="readonly"
      type="checkbox"
      @change="emit('toggle', task.id, !task.done)"
    />
    <span>{{ task.title }}</span>
    <button v-if="!readonly" @click="emit('remove', task.id)">删除</button>
  </article>
</template>

注意这个子组件全程没改过 task: 勾选框变了它只是 emit 一个 toggle 事件, 把”用户想改”这个意图上报, 真正改数组的是持有数据的父组件。直接改对象型 prop 的字段技术上可能”生效”, 但父组件的数据被人背后动了自己却不知道, 排查时就是灾难。

事件名起”发生了什么”: submitremoveselection-change, 别起 changeData 这种谁也看不出含义的名字。

3. 组件 v-model

想让自己封装的输入组件像原生 <input> 一样支持 v-model, Vue 3.4 之后一个宏搞定:

<!-- BaseSearchInput.vue -->
<script setup lang="ts">
const model = defineModel<string>({ default: '' })  // 自动生成 prop + 事件那套
</script>

<template>
  <input v-model.trim="model" type="search" />
</template>
<BaseSearchInput v-model="keyword" />

它底层还是老约定: 一个叫 modelValue 的 prop 加一个 update:modelValue 事件。老项目里满地都是手写版, 得认识:

代码块TS · 2 行收起展开
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()

一个组件可以有多个模型, 用具名的 v-model:visiblev-model:page。但别把一次性动作伪装成双向绑定, 点击确认就该 emit 一个 confirm 事件, 而没有什么值需要”同步”。

4. 插槽: 让父组件往子组件里塞结构

props 传的是数据, 但有时父组件想塞给子组件的是一段自己写的 HTML 结构, 比如面板组件的标题区、空状态提示。这就是插槽 (slot): 子组件挖坑, 父组件填内容。

<!-- DataPanel.vue (子): 挖了三个坑 -->
<template>
  <section>
    <header><slot name="header" /></header>          <!-- 具名插槽 -->
    <p v-if="loading">加载中...</p>
    <slot v-else-if="rows.length" :rows="rows" />     <!-- 默认插槽, 还把 rows 递出去 -->
    <slot v-else name="empty"><p>暂无数据</p></slot>  <!-- 标签里的内容是默认兜底 -->
  </section>
</template>
<!-- 父组件: 填坑 -->
<DataPanel :rows="tasks" :loading="loading">
  <template #header><h2>任务</h2></template>
  <template #default="{ rows }">   <!-- 作用域插槽: 接住子组件递出来的数据 -->
    <TaskRow v-for="task in rows" :key="task.id" :task="task" />
  </template>
  <template #empty><EmptyState action-text="新建任务" /></template>
</DataPanel>

作用域插槽 (:rows="rows" 那下) 是最妙的设计: 数据是子组件的 (它管加载状态), 长什么样父组件说了算。Element Plus 表格的自定义列就是这套机制。

5. provide/inject: 跨层传递

祖先组件的东西要给隔了好几层的后代用, 逐层 props 转发太机械。provide/inject 让祖先直接”广播”给整棵子树:

代码块TS · 7 行收起展开
// 祖先 (比如 BaseForm): 提供
const disabled = ref(false)
provide(formKey, { disabled: readonly(disabled) })  // readonly: 只许看, 修改权留给自己

// 深层后代 (比如 FormField): 注入
const form = inject(formKey)
if (!form) throw new Error('FormField 必须在 BaseForm 内使用')

适用场景很明确: 表单上下文、主题这类”一个祖先提供、一片后代消费”的局部依赖。它对使用位置有隐性要求 (必须在提供者子树内), 所以不要拿它当全局状态用, 跨页面的东西归 Pinia。

6. 模板引用与 defineExpose

<script setup> 的组件默认对外封闭, 父组件拿到引用也调不到内部函数, 想暴露得显式声明:

<!-- BaseForm.vue -->
<script setup lang="ts">
function validate() { /* ... */ }
defineExpose({ validate })    // 白名单式暴露
</script>
// 父组件
const formRef = ref<InstanceType<typeof BaseForm> | null>(null)
await formRef.value?.validate()

这类命令式调用适合 focus、校验、打开弹窗这种”动作”, 常规数据同步还是 props/emit。如果发现自己到处在写 xxxRef.value?.doSomething(), 组件间已经耦死了, 该反思边界。

7. 组合式函数 useXxx

写法对比篇提过一嘴, 这里给设计标准。组合式函数复用的是”带状态的逻辑”, 和纯函数工具 (utils) 是两个物种:

代码块TS · 16 行收起展开
// composables/usePagination.ts: 任何列表页都能用的分页逻辑
import { computed, ref } from 'vue'

export function usePagination(initialSize = 20) {
  const page = ref(1)
  const pageSize = ref(initialSize)
  const total = ref(0)

  const offset = computed(() => (page.value - 1) * pageSize.value)

  function resetPage() {
    page.value = 1
  }

  return { page, pageSize, total, offset, resetPage }  // 返回 ref 本身, 别解构成死值
}

自己写 useXxx 时的几条规矩: 返回 ref 而不要提前 .value 拆掉; 里面注册的生命周期要在组件 setup 阶段同步调用才绑得上; 有请求要支持取消、有监听要负责清理; 最后, 别造万能的 useCommon, 那和一个 3000 行的 Utils 类是同一种腐烂。

8. 动态与异步组件

<component :is="xxx"> 按变量渲染组件 (标签页切换的标准解法); defineAsyncComponent 让大组件用到时才下载:

代码块TS · 5 行收起展开
const MarkdownEditor = defineAsyncComponent({
  loader: () => import('./MarkdownEditor.vue'),  // 动态 import: 单独打包, 按需加载
  loadingComponent: EditorSkeleton,
  timeout: 10_000
})

适合大编辑器、图表、低频弹窗。首屏立刻要用的小组件别懒加载, 徒增请求和闪烁。

<KeepAlive> 能把切走的动态组件缓存住不销毁 (保留滚动位置、表单草稿), 缓存的组件不走正常卸载, 清理逻辑要挪到 onDeactivated

9. 通信方式怎么选

场景用什么别用什么
父传子props子组件自己读全局变量
子通知父emit直接改父状态
父定制子结构slot一堆布尔 prop 组合爆炸
深层局部上下文provide/inject逐层 props 透传
跨页面共享PiniaEvent Bus (事件满天飞没法追踪)
刷新/分享要保留的状态路由 query/params只存 Store (刷新就丢)

Vue 2 老代码里的 $parent、Event Bus 读得懂就行, 新代码别写, 它们的共同问题是数据流向没法顺着代码追。

10. 一个拆分案例

任务管理页, 我会这么拆:

任务管理页组件树

所有业务状态都在 TaskPage 手里, 子组件各自只管自己那摊。回头看第 1 节的单向数据流, 这张图就是它的具象。

11. 动手验收

  • 封装一个支持 v-model、清空按钮、禁用态的输入组件。
  • 封装 BaseDialog: v-model:visible + 默认插槽 + footer 插槽。
  • 把窗口 resize 监听封装成 useWindowSize, 验证组件卸载后监听真的移除了。
  • 把一个 300 行的页面按职责拆开, 说清每个状态归谁持有。

延伸阅读