【Vue3】day74-props-emit-slot

作者:mario 发布时间: 2026-09-03 阅读量:5 评论数:0

Day 74 · props / emit / slot — 组件化第一课:装配三件套

逻辑已就绪(Day 73),今天学把逻辑和结构装配成可复用组件的三个通道:props(父 → 子的数据)、emit(子 → 父的事件)、slot(父 → 子的内容分发)。实战两个组件:TechCard(Day 66 工业风卡片的 Vue 版,slot 的最佳教具)和 AlarmList(告警列表,props + emit + 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 会与原生事件混淆(尤其组件根元素)。规范:事件名语义化(ackclear-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

评论