组件设计与数据流
Vue 组件设计与数据流
快速上手篇里 props/emits 已经见过一面, 这篇把组件这件事真正想明白: 什么时候拆、怎么定边界、父子和跨层之间数据怎么流。我的体感是, Vue 语法学完之后, 真实项目里 80% 的纠结都发生在这一层, 就像 Java 语法都会但类怎么设计才是真功夫。
1. 组件是什么, 什么时候拆
组件就是前端的”类”: 把一块界面的结构、样式、逻辑封装成一个可复用单元, 对外只暴露参数 (props) 和事件 (emits)。页面则是组件拼出来的。
什么时候拆? 我用三个问题判断: 这块会不会在别处复用 (复用性); 它是不是一个能独立说清楚的职责, 比如”筛选栏""编辑弹窗” (单一职责); 拆出去后接口是不是清爽, 传三个 props 出两个事件就能用 (低耦合)。
全是”是”就拆。反过来, 拆组件的目的从来都只是”500 行拆成 5 个 100 行文件”这种物理搬运的话, 拆了也白拆。
还有一条铁律叫单向数据流: 状态归父组件持有, 子组件只负责展示和”报告发生了什么”, 从不亲手改父组件的数据。数据只有一个主人, 出了错才知道去哪查。
2. Props 与 Emits: 父子之间的契约
先明确谁是父谁是子: A 组件 import 了 B 并写在自己模板里, A 就是父。父往下传数据用 props, 子往上报事件用 emits:
代码块收起展开
<!-- 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 的字段技术上可能”生效”, 但父组件的数据被人背后动了自己却不知道, 排查时就是灾难。
事件名起”发生了什么”: submit、remove、selection-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 事件。老项目里满地都是手写版, 得认识:
代码块收起展开
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()一个组件可以有多个模型, 用具名的 v-model:visible、v-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 让祖先直接”广播”给整棵子树:
代码块收起展开
// 祖先 (比如 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) 是两个物种:
代码块收起展开
// 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 让大组件用到时才下载:
代码块收起展开
const MarkdownEditor = defineAsyncComponent({
loader: () => import('./MarkdownEditor.vue'), // 动态 import: 单独打包, 按需加载
loadingComponent: EditorSkeleton,
timeout: 10_000
})适合大编辑器、图表、低频弹窗。首屏立刻要用的小组件别懒加载, 徒增请求和闪烁。
<KeepAlive> 能把切走的动态组件缓存住不销毁 (保留滚动位置、表单草稿), 缓存的组件不走正常卸载, 清理逻辑要挪到 onDeactivated。
9. 通信方式怎么选
| 场景 | 用什么 | 别用什么 |
|---|---|---|
| 父传子 | props | 子组件自己读全局变量 |
| 子通知父 | emit | 直接改父状态 |
| 父定制子结构 | slot | 一堆布尔 prop 组合爆炸 |
| 深层局部上下文 | provide/inject | 逐层 props 透传 |
| 跨页面共享 | Pinia | Event Bus (事件满天飞没法追踪) |
| 刷新/分享要保留的状态 | 路由 query/params | 只存 Store (刷新就丢) |
Vue 2 老代码里的 $parent、Event Bus 读得懂就行, 新代码别写, 它们的共同问题是数据流向没法顺着代码追。
10. 一个拆分案例
任务管理页, 我会这么拆:
所有业务状态都在 TaskPage 手里, 子组件各自只管自己那摊。回头看第 1 节的单向数据流, 这张图就是它的具象。
11. 动手验收
- 封装一个支持
v-model、清空按钮、禁用态的输入组件。 - 封装
BaseDialog:v-model:visible+ 默认插槽 + footer 插槽。 - 把窗口 resize 监听封装成
useWindowSize, 验证组件卸载后监听真的移除了。 - 把一个 300 行的页面按职责拆开, 说清每个状态归谁持有。