Day 74 · props / emit / slot — 组件化第一课:装配三件套
逻辑已就绪(Day 73),今天学把逻辑和结构装配成可复用组件的三个通道:props(父 → 子的数据)、emit(子 → 父的事件)、slot(父 → 子的内容分发)。实战两个组件:
TechCard(Day 66 工业风卡片的 Vue 版,slot 的最佳教具)和AlarmList(告警列表,props + emit + v-for + key 的综合演练)。
目录
- 一、props:父到子的单向数据流
- 二、emit:子到父的事件上报
- 三、slot:内容分发的三个层次
- 四、实战:TechCard 组件
- 五、实战:AlarmList 组件
- 六、v-for 的 key 纪律
- 七、常见坑点
- 八、自测挑战
- 九、总结
一、props:父到子的单向数据流
<script setup lang="ts">
// TS 泛型写法:类型即声明,编译期校验(团队规范唯一写法)
const props = defineProps<{
title: string; // 必传
accent?: "blue" | "green" | "red"; // 可选,语义化强调色
}>();
</script>
单向数据流铁律:props 是父组件的"投影",子组件只读。改它会收获两重惩罚:运行时警告 + 数据源不一致的 bug。
子组件要"改"props 的两个正规出口:
| 意图 | 正规做法 |
|---|---|
| 派生显示值 | computed(() => props.xxx ...)(Day 73) |
| 真正修改数据 | emit 上报事件,由父组件改源(第二节) |
// ❌ 违规:直接改 props
props.alarms.shift();
// ✅ 正规:本地 computed 派生
const top10 = computed(() => props.alarms.slice(0, 10));
// ✅ 正规:要删就上报
emit("clear");
二、emit:子到父的事件上报
<script setup lang="ts">
// 泛型声明:事件名 → 参数类型(父组件模板里有类型提示)
const emit = defineEmits<{
(e: "clear"): void; // 清空告警,无参数
(e: "ack", id: string): void; // 确认某条告警,带 id
}>();
// 上报:语义是"我发生了什么",不是"你去干什么"(事件名用过去式/名词)
function onAckClick(id: string): void {
emit("ack", id);
}
</script>
父组件消费:
<AlarmList :alarms="sortedAlarms" @ack="onAck" @clear="onClearAll" />
🎯 原生版对照:Day 65 Store 的订阅回调(
onAlarms)本质就是"子逻辑向宿主上报"——emit 是同一思想的组件化:通信靠声明契约(事件签名),不靠直接调用。
三、slot:内容分发的三个层次
| 层次 | 语法 | 大屏用途 |
|---|---|---|
| 默认插槽 | <slot /> |
TechCard 的内容区(装图表/装文字都行) |
| 具名插槽 | <slot name="extra" /> + #extra |
卡片右上角的"更多/状态"位 |
| 作用域插槽 | <slot :row="item" /> + #default="{ row }" |
AlarmList 把每行数据回传给父级渲染(本周暂不用,知道存在即可) |
slot 的本质:组件的结构由使用者填充,组件只提供骨架与风格——这正是 TechCard "标题 + 边框"与"内容无关"的抽象。
四、实战:TechCard 组件
<!-- src/components/TechCard.vue -->
<script setup lang="ts">
/**
* 工业风卡片容器(迁移自 Day 66 的 createTechCard)
* 原生版:函数返回 { root, body },调用方 append 到 DOM 树
* Vue 版:props 传标题,slot 装内容,使用者只写标签
*
* 结构:┌─ 切角边框 ─┐
* │ ▍标题 [extra] │ ← 具名插槽:右上角扩展位
* │ [默认插槽] │
* └───────────┘
*/
const props = defineProps<{
title: string;
accent?: "blue" | "green" | "red"; // 强调色(默认 blue)
}>();
</script>
<template>
<div class="tech-card" :data-accent="accent ?? 'blue'">
<div class="tech-card__title">
<span class="tech-card__bar"></span>
{{ title }}
<!-- 具名插槽:父级可用 #extra 放状态徽标、"更多"按钮 -->
<span class="tech-card__extra"><slot name="extra" /></span>
</div>
<div class="tech-card__body">
<!-- 默认插槽:内容区,图表/列表/KPI 都从这装 -->
<slot />
</div>
</div>
</template>
<style scoped>
/* 视觉 100% 复刻 Day 66:token 引用、切角、发光——一个像素都不许差 */
.tech-card {
background: var(--bg-card);
border: 1px solid var(--border-card);
border-radius: var(--radius-card);
clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 12px 100%, 0 calc(100% - 12px));
padding: var(--gap-md);
}
.tech-card__title {
display: flex; align-items: center; gap: var(--gap-sm);
font-size: var(--fs-title); color: var(--text-title);
}
.tech-card__bar { width: 4px; height: 14px; background: var(--raw-blue); }
.tech-card[data-accent="danger"] .tech-card__bar { background: var(--state-danger); }
</style>
使用效果(声明式的兑换率直观体现):
<TechCard title="实时温度监控">
<LineChart :windows="tempWindows" /> <!-- 默认插槽 -->
<template #extra>
<span class="badge-warn">{{ dangerCount }} 条告警</span> <!-- 具名插槽 -->
</template>
</TechCard>
对照原生版:createTechCard("标题") 返回 DOM 再手动 append 三步;现在是标签一包完事,且 TypeScript 能校验 title 必传。
五、实战:AlarmList 组件
<!-- src/components/AlarmList.vue -->
<script setup lang="ts">
/**
* 告警滚动列表(迁移自 Day 63 的告警条 + Day 69 的告警列表坑位)
* 职责边界(关键设计):
* 本组件 = 展示 + 上报(ack/clear),不做业务修改
* 排序/过滤/截断 = 父级 computed 链(Day 73 第五节),保持展示组件的"傻"
*/
defineProps<{
alarms: Alarm[]; // 已排序截断的告警(父级派生)
}>();
const emit = defineEmits<{
(e: "ack", id: string): void;
(e: "clear"): void;
}>();
/** 时间格式化:HH:mm:ss(纯函数,展示组件可持有) */
const fmtTime = (ts: number): string =>
new Date(ts).toLocaleTimeString("zh-CN", { hour12: false });
</script>
<template>
<div class="alarm-list">
<div v-if="alarms.length === 0" class="alarm-list__empty">暂无告警</div>
<!-- key 用业务 id(纪律见第六节) -->
<div
v-for="a in alarms"
:key="a.id"
class="alarm-row"
:data-level="a.level"
>
<span class="alarm-row__time">{{ fmtTime(a.ts) }}</span>
<span class="alarm-row__text">{{ a.text }}</span>
<button class="alarm-row__ack" @click="emit('ack', a.id)">确认</button>
</div>
</div>
</template>
设计要点:展示组件只管显示和上报,不持业务状态——这让它在屏 A(告警滚动条)和屏 B(完整列表)两种场景都能复用。
六、v-for 的 key 纪律
6.1 为什么 key 不能用 index
告警列表是"头部插入 + 底部截断"的动态列表。用 :key="index" 时:
新告警到达,列表整体下移一位:
index 0:老内容 → 新告警 ← 所有行都被 Vue 认成"变了内容"
index 1:老内容0 → 老内容0
...
Vue 的 diff 按 key 对齐节点:index 作为 key 意味着"每个位置都是同一个节点",于是所有行的文本都要打补丁;更糟的是带动画/过渡的行会全部重播。用业务 id(a.id):
新告警到达:
key "alarm-1024" 是全新节点 → 只在头部插入一个
其余 key 不变 → 原节点复用,零补丁
6.2 key 的通用规则
- 用稳定、唯一、业务语义的标识(告警 id、设备 id)
- 数组是"纯展示且永不重排"时 index 才可接受(大屏里几乎没有这种列表)
- key 不要用对象本身(
:key="a"),对象引用每次可能新建
七、常见坑点
坑 1:props 解构丢响应性(Vue 3.5 前的行为差异)
老写法 const { title } = defineProps<...>() 在多数版本下会失去响应性追踪优化甚至报错。规范:模板用 props.title 或直接 title,script 一律 props.title。
坑 2:在子组件里改 props 触发警告
数据流断裂的经典信号。定位三问:这是派生吗(→ computed)?这是修改吗(→ emit)?这是本地 UI 状态吗(→ 子组件自己建 ref,如"展开/收起")。
坑 3:slot 内容的作用域
插槽内容编译在父组件作用域——里面引用的变量来自父级,不是子组件。报"变量未定义"时先想到这一条。
坑 4:emit 的事件名与 HTML 原生事件撞车
自定义事件 change/click 会与原生事件混淆(尤其组件根元素)。规范:事件名语义化(ack、clear-all),不与原生重名。
坑 5:TechCard 里塞业务逻辑
"顺手"在卡片组件里写了取数和排序——复用性当场归零。每次想加功能先问:这是"容器"的事还是"内容"的事?容器只管骨架和风格。
八、自测挑战
T1 · TechCard 完整迁移(40 分钟)
完成第四节组件,在屏 B 占位页摆三个卡片(含 #extra 插槽用法),与原生版截图逐像素对照(视觉零变化验收)。
T2 · AlarmList + key 实验(50 分钟)
完成第五节组件;故意把 :key 换成 index,用"每秒头部插入一条告警"的模拟数据观察两种 key 下的 DOM 补丁差异(DevTools Elements 里高亮闪烁对比),把结论写进 NOTES.md。
T3 · 单向数据流违规实验(30 分钟)
在 AlarmList 里直接 props.alarms.pop(),观察警告;再分别用 computed 派生和 emit 上报改写,体会三个出口的分工。
九、总结
| 通道 | 方向 | 纪律 |
|---|---|---|
| props | 父 → 子 | 只读;派生用 computed,修改走 emit |
| emit | 子 → 父 | 泛型签名;事件名语义化不撞原生 |
| slot | 父填充子骨架 | 默认/具名够用;作用域插槽按需 |
| v-for key | — | 业务 id,禁 index(动态列表) |
| 组件边界 | — | 容器不做业务;展示组件保持"傻" |
明天把数据层的脏活(订阅/退订/生命周期)收进组合式函数——useDataSource。