Day 72 · 响应式核心 — ref / reactive / shallowRef 与模板语法
响应式是 Vue 的心脏,也是"命令式 → 声明式"转型的地基。今天用三个实战组件吃透它:标题栏状态灯(连接状态的响应式映射)、屏切换的 v-show(对照 Day 64 的 opacity 策略)、大快照的 shallowRef(性能视角的响应式)。原则:每个 API 都在大屏里找到它的位置,不背概念。
目录
- 一、ref:默认选择
- 二、reactive 与它的解构陷阱
- 三、shallowRef:大快照的正确姿势
- 四、模板语法速览:大屏用到的 80%
- 五、v-if vs v-show:三屏互斥的选型
- 六、实战:状态灯与标题栏组件
- 七、常见坑点
- 八、自测挑战
- 九、总结
一、ref:默认选择
import { ref } from "vue";
// 任意类型的单一值:基础类型、对象、Map 都可以
const connState = ref<"connected" | "connecting" | "closed">("closed");
const latest = ref<PanelSnapshot | null>(null);
// script 中读写都要 .value;模板中自动解包
connState.value = "connected";
团队规范:默认 ref。理由:
- 基础类型只有 ref 能装(reactive 不支持)
- 整体替换赋值语义清晰(
snapshot.value = newSnap),reactive 整体替换会断开代理 .value是显式的"我要动响应式状态了"标记,代码审查时一眼可辨
二、reactive 与它的解构陷阱
import { reactive } from "vue";
// reactive:对象的深层代理,适合"结构稳定的组态对象"
const layout = reactive({
currentScreen: "A",
autoplay: true,
dwellSec: { A: 15, B: 20, C: 15 },
});
// ✅ 属性读写直接来,无 .value
layout.autoplay = false;
// ❌ 陷阱一:解构丢响应性——拷贝出了普通值
const { autoplay } = layout; // autoplay 从此是死数据
// 修复:toRefs 保持响应式连接
const { autoplay } = toRefs(layout); // 现在是 ref,模板里可用
// ❌ 陷阱二:整体替换断开代理
layout = { ...newLayout }; // 编译报错(const)+即使 let 也丢响应
大屏使用守则:结构固定、整体不变、逐字段更新的"配置/组态"用 reactive(如轮播配置);数据流(快照、告警、窗口)一律 ref——它们是"整体到达、整体替换"的形态。
三、shallowRef:大快照的正确姿势
Day 65 的 Store 每 500ms 收到一次 PanelSnapshot(多设备多字段的大对象)。如果用 ref:
const latest = ref<PanelSnapshot | null>(null);
latest.value = newSnap; // ❌ Vue 会递归遍历整个对象做深层代理
// 每秒两次深代理 = 无谓的 GC 压力
大屏快照的特征:从不原地改字段,永远整体替换。这正是 shallowRef 的适用场景:
import { shallowRef, triggerRef } from "vue";
const latest = shallowRef<PanelSnapshot | null>(null);
latest.value = newSnap; // ✅ 只追踪 .value 的替换,不深入对象内部
// 引用变了 → 通知更新,性能开销 O(1)
🎯 选型三连:单一值 →
ref;结构稳定的组态 →reactive;大而整体替换的数据(快照/窗口 Map)→shallowRef。这条纪律直接决定 Day 77 性能对比的成绩单。
四、模板语法速览:大屏用到的 80%
<template>
<!-- 1. 插值:文本({{ }})与属性(:attr) -->
<span>{{ connState }}</span>
<div :class="{ 'is-danger': connState === 'closed' }">●</div>
<!-- 2. 列表:v-for + key(key 纪律见 Day 74) -->
<li v-for="alarm in alarms" :key="alarm.id">{{ alarm.text }}</li>
<!-- 3. 条件渲染:v-if(真移除)与 v-show(display 切换) -->
<div v-if="alarms.length === 0" class="empty">暂无告警</div>
<div v-show="isActive">…</div>
<!-- 4. 事件:@event,内联或方法名 -->
<button @click="toggleAutoplay">暂停轮播</button>
<!-- 5. 双向绑定:表单场景才用(大屏少用,意识到位即可) -->
<input v-model="dwellSec" />
</template>
和原生版的对照:v-for = 你的 forEach + createElement + append;:class = 你的 classList.toggle;v-if = remove/append。一行模板换十行 DOM 手艺,这就是声明式的兑换率。
五、v-if vs v-show:三屏互斥的选型
| v-if | v-show | |
|---|---|---|
| 实现 | 真挂载/卸载 DOM | display: none 切换 |
| 切换成本 | 高(重建组件树、ECharts 重新 init) | 极低 |
| 初始成本 | 低(不渲染) | 高(全量渲染) |
Day 64 的架构决策(三屏隐藏用 opacity 而非 display/移除,ECharts 隐藏态 init、切屏秒恢复)在 Vue 里的映射就是 v-show:
<!-- 三屏互斥:v-show 保证三屏始终挂载(ECharts 实例常驻) -->
<ScreenA v-show="current === 'A'" />
<ScreenB v-show="current === 'B'" />
<ScreenC v-show="current === 'C'" />
<!-- 但需要注意:v-show 用的是 display:none,隐藏容器宽高为 0,
Day 52 坑 1(隐藏态图表)依然存在 → 切屏后 resize 的纪律照样要守 -->
⚠️ v-show 的 display:none 与 Day 64 的 opacity 策略有差异:display:none 下容器尺寸为 0,首次 init 会失败。两种解法:(1) 首屏渲染完再 init(nextTick 后再 echarts.init);(2) 隐藏类改回 opacity 方案(全局 CSS 覆盖)。Day 76 封装 VChart 时统一解决,今天先知道这个坑的存在。
六、实战:状态灯与标题栏组件
<!-- components/ConnIndicator.vue -->
<script setup lang="ts">
/**
* 连接状态灯:全屏共享的顶栏组件
* 迁移自原生版 Day 63 的状态灯——当时是 onState 回调改 classList,
* 现在是 connState(ref)到 class 的声明式映射
*/
import { computed } from "vue";
// props 由父级传入(Day 74 详讲,今天先用最简形式)
const props = defineProps<{ state: "connected" | "connecting" | "closed" }>();
// 派生状态:文案与配色(computed 详解见 Day 73,这里先感受"状态映射")
const label = computed(
() => ({ connected: "已连接", connecting: "连接中…", closed: "连接断开" })[props.state]
);
</script>
<template>
<span class="conn" :data-state="state">
<i class="conn__dot" />
{{ label }}
</span>
</template>
<style scoped>
.conn { display: inline-flex; gap: 6px; align-items: center; font-size: var(--fs-dim); }
.conn__dot { width: 8px; height: 8px; border-radius: 50%; }
/* 状态色全部来自 Day 66 token——视觉零变化 */
.conn[data-state="connected"] .conn__dot { background: var(--state-ok); }
.conn[data-state="connecting"] .conn__dot { background: var(--state-warn); }
.conn[data-state="closed"] .conn__dot { background: var(--state-danger); }
</style>
使用处(App.vue 标题栏):
<script setup lang="ts">
import { ref } from "vue";
import ConnIndicator from "./components/ConnIndicator.vue";
// 暂时手动驱动状态验证组件,Day 75 接入真实数据源
const connState = ref<"connected" | "connecting" | "closed">("connecting");
setTimeout(() => (connState.value = "connected"), 2000);
</script>
<template>
<header class="top-bar">
<h1>智慧工厂监控中心</h1>
<ConnIndicator :state="connState" />
</header>
</template>
七、常见坑点
坑 1:reactive 解构后"数据不动了"
从 reactive 对象解构出的变量是普通值,后续对象更新它不跟随。修复:toRefs / toRef,或干脆用 ref。
坑 2:ref 装对象后误以为深层不响应
ref(obj) 是深层响应的(内部对对象调用 reactive)。想要"只追踪整体替换"必须显式 shallowRef。区分清楚,别在性能敏感处用错。
坑 3:模板里对 ref 写 .value
模板自动解包,写 {{ scale.value }} 会显示数值的字符串包装或报错。记忆口诀:模板不见 value,script 必见 value。
坑 4:v-show 隐藏态初始化图表失败
display:none 容器宽高为 0,ECharts init 出 0×0 画布。今天先规避(暂不 init),Day 76 在 VChart 组件里用 nextTick + 激活 resize 系统性解决。
八、自测挑战
T1 · 三类响应式选型练习(30 分钟)
把大屏的六种状态(连接状态 / 最新快照 / 温度窗口 Map / 轮播配置 / 告警数组 / 缩放系数)分别选型 ref / reactive / shallowRef 并写理由。写在 NOTES.md。
T2 · 状态灯 + 标题栏组件(60 分钟)
完成第六节组件:三种状态自动轮换(setInterval 每 3s 切换 connected → connecting → closed),观察灯色与文案跟随。然后故意把 connState 改成 reactive 对象的解构变量,验证"数据死了"的坑 1。
T3 · v-show 三屏切换(30 分钟)
App.vue 里加 current ref 和三个切换按钮,用 v-show 互斥显示三屏占位组件。为三屏写全局 .screen 布局类(非 scoped),确认切换无白屏。
九、总结
| 概念 | 大屏对应物 | 纪律 |
|---|---|---|
| ref | 连接状态、告警数组、缩放系数 | 默认选择,script 里 .value |
| reactive | 轮播配置等结构稳定组态 | 禁解构(要解构用 toRefs)、禁整体替换 |
| shallowRef | 快照、温度窗口 | 大对象整体替换的唯一正确姿势 |
| v-show | 三屏互斥 | 保 ECharts 常驻;隐藏态尺寸坑 Day 76 解决 |
| :class / :data-state | 状态 → 视觉映射 | 状态色走 token,不写死色值 |
明天给响应式装上"自动计算与联动"的引擎:computed、watch 与生命周期。