Day 73 · computed / watch / 生命周期 — 让状态自己流动起来
昨天学会了"存状态",今天学"状态之间的传导":computed(派生状态)、watch(联动副作用)、生命周期钩子(成对清理纪律)。实战对象是 Day 67 的 KPI 数字翻牌器——它恰好是三者的完美交汇:computed 算显示值,watch 触发滚动动画,onUnmounted 取消 rAF。今天产出第一个组合式函数
useRollingNumber。
目录
- 一、computed:派生状态只算一次
- 二、watch:副作用的三种姿势
- 三、生命周期:成对纪律的 Vue 版
- 四、实战:useRollingNumber 组合式函数
- 五、实战:告警派生状态(computed 链)
- 六、常见坑点
- 七、自测挑战
- 八、总结
一、computed:派生状态只算一次
import { computed, ref, shallowRef } from "vue";
const latest = shallowRef<PanelSnapshot | null>(null);
// 派生 KPI:依赖(latest)不变就返回缓存,变了自动重算
const avgTemp = computed(() => {
if (!latest.value) return 0;
const ds = latest.value.devices;
return ds.reduce((s, d) => s + d.temperature, 0) / ds.length;
});
原生版的对照:Day 63 面板里你写过"快照到了 → 手动重算平均温 → 手动 set 内容"三步。computed 把"重算"变成自动的——你只负责声明 avgTemp 是什么,不负责什么时候算。
纪律:
- computed 里禁止副作用(不改状态、不操作 DOM)——它是"纯函数",混入副作用会让缓存机制变黑洞
- 能 computed 的不要 watch——watch + 中转变量是 computed 的笨重写法(常见坏味道)
二、watch:副作用的三种姿势
import { watch, watchEffect } from "vue";
// 姿势一:明确源 + 回调(生产首选,依赖显式)
watch(latest, (snap, oldSnap) => {
console.log(`seq: ${oldSnap?.seq} → ${snap.seq}`);
});
// 姿势二:deep(仅 reactive/对象源需要;shallowRef 整体替换天然不需要)
watch(layoutState, () => saveConfig(), { deep: true });
// 姿势三:watchEffect(自动收集依赖,慎用——依赖是隐式的)
watchEffect(() => {
document.title = `告警 ${alarms.value.length} 条`;
});
选型纪律:
- 副作用目标明确(“A 变了就做 B”)→
watch(A, B),一眼看懂 - "依赖谁不确定、自动追踪才方便"的胶水逻辑 →
watchEffect(大屏里几乎不用,避免读代码时追踪依赖) - 性能红线:对 shallowRef 的大快照禁用
deep: true——那等于亲手把 shallow 的优化打回原形
三、生命周期:成对纪律的 Vue 版
Day 69 排查内存泄漏的核心结论——订阅/监听必须成对出现——在 Vue 里映射为钩子的成对:
| 成对关系 | 钩子 | 大屏实例 |
|---|---|---|
| addEventListener ↔ removeEventListener | onMounted ↔ onUnmounted | scale 壳的 resize(Day 71 已写) |
| setInterval ↔ clearInterval | onMounted ↔ onUnmounted | 时钟、轮播计时 |
| requestAnimationFrame ↔ cancelAnimationFrame | 任意时机启动 ↔ onUnmounted | 翻牌器动画(今天) |
| store.subscribe ↔ unsub() | onMounted ↔ onUnmounted | 数据订阅(Day 75 的重头戏) |
onMounted(() => {
timer = setInterval(tick, 1000);
});
onUnmounted(() => {
clearInterval(timer); // 忘了这行 = Day 69 坑 4 的 Vue 复刻
});
🎯 心智对照:原生版的"activate/deactivate 自觉成对",Vue 用组件树挂载/卸载强制给了你这对时机——框架的价值就是把纪律变成结构。
四、实战:useRollingNumber 组合式函数
4.1 什么是组合式函数
组合式函数(Composable)= 用 use 开头命名、内部使用响应式 API、封装"有状态的逻辑"的函数。组件负责"状态 → 模板",组合式函数负责"逻辑的跨组件复用"。Day 67 的翻牌器是纯 DOM 工艺(闭包 + rAF),今天把它改造成响应式世界的原住民。
4.2 实现
// src/composables/useRollingNumber.ts
import { onUnmounted, ref, watch } from "vue";
/**
* KPI 数字翻牌器(组合式函数版,迁移自 Day 67 的 createRollingNumber)
* @param source 目标数值的 ref(外部数据驱动)
* @param opts 动画配置:时长与小数位
* @returns display —— 可直接用于模板的显示值 ref
*
* 设计要点:
* 1. 源变了才动:watch 源值触发滚动,而非每帧盲跑
* 2. 组件卸载自动取消 rAF:onUnmounted 兜底(宿主组件不需要知情)
* 3. 返回 ref 而非操作 DOM:与模板解耦,任何 <span>{{ display }}</span> 可用
*/
export function useRollingNumber(
source: Ref<number>,
opts = { duration: 800, decimals: 0 }
): Ref<string> {
const display = ref("0"); // 显示值(含千分位)
let rafId = 0;
let current = 0; // 动画起始值(上一次的最终值)
/** 千分位格式化:125800 → "125,800"(Day 67 同款) */
const fmt = (v: number): string =>
v.toLocaleString("zh-CN", {
minimumFractionDigits: opts.decimals,
maximumFractionDigits: opts.decimals,
});
// 源值变化 → 播放插值滚动
watch(source, (target) => {
cancelAnimationFrame(rafId);
const from = current;
const start = performance.now();
const frame = (now: number): void => {
const t = Math.min((now - start) / opts.duration, 1);
const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic,全项目统一
current = from + (target - from) * eased;
display.value = fmt(current);
if (t < 1) rafId = requestAnimationFrame(frame);
};
rafId = requestAnimationFrame(frame);
});
// 宿主组件卸载 → 取消进行中的动画(成对纪律)
onUnmounted(() => cancelAnimationFrame(rafId));
return display;
}
4.3 使用:一个 KPI 卡片的全部逻辑
<!-- components/KpiTile.vue(简化版,Day 74 补 props) -->
<script setup lang="ts">
import { computed, ref } from "vue";
import { useRollingNumber } from "../composables/useRollingNumber";
const latest = shallowRef<PanelSnapshot | null>(null); // Day 75 换成全局数据
// 派生:今日产量 KPI
const output = computed(() => latest.value?.kpi.output ?? 0);
// 翻牌显示值:output 变 → 自动滚动
const outputDisplay = useRollingNumber(output, { duration: 800 });
</script>
<template>
<!-- 等宽字体(Day 66 token):跳动不移位 -->
<div class="kpi-tile">
<span class="kpi-tile__label">今日产量</span>
<span class="kpi-tile__value">{{ outputDisplay }}</span>
</div>
</template>
与 Day 67 原生版的对照(记进 NOTES.md):调用方从"拿到 update() 自己找时机调"变成"改一个数";清理从"调用方自觉 cancel"变成"框架兜底"。逻辑没变,责任转移了——这就是组合式函数的兑换率。
五、实战:告警派生状态(computed 链)
// 告警数据的两层派生:原始列表 → 未确认 → 按时间倒序
const alarms = shallowRef<Alarm[]>([]);
const activeAlarms = computed(() =>
alarms.value.filter((a) => !a.acked)
);
const sortedAlarms = computed(() =>
[...activeAlarms.value].sort((a, b) => b.ts - a.ts).slice(0, 50) // 上限截断(Day 69 排查位 2)
);
// 危险等级计数:状态灯旁边的小徽标
const dangerCount = computed(() =>
sortedAlarms.value.filter((a) => a.level === "danger").length
);
computed 链的求值是惰性且缓存的:快照没变,sortedAlarms 就是缓存值;快照变了,链条自动按需重算。原生版里这些"手动维护的中间变量"全部消失。
六、常见坑点
坑 1:watch 里改被 watch 的源 → 死循环
watch(a, (v) => { a.value = transform(v) }) 无限触发。修复:源与派生分清——派生用 computed,真正要"回写"时引入独立的中止条件。
坑 2:onUnmounted 写在了异步回调之后
setup() 是同步的,钩子必须同步注册。setTimeout(() => onUnmounted(...)) 无效(组件已卸载无钩可挂)。修复:钩子注册放同步顶层,清理动作用变量闭包。
坑 3:组合式函数里 onUnmounted 的宿主歧义
组合式函数在调用它的组件的 setup 上下文中注册钩子——所以 useRollingNumber 必须在 setup 顶层同步调用,不能在事件回调里调(没有宿主上下文)。这是所有 Composable 的通用纪律(Day 75 展开)。
坑 4:computed 里发请求 / 操作 DOM
破坏纯函数约定,缓存机制下的执行时机不可预期。数据获取用 watch(或 Week 12 的组件级方案),DOM 操作用 ref 引用 + watchEffect 模板引用。
七、自测挑战
T1 · 三概念辨析(20 分钟)
不看书回答:computed 与 watch 的分工边界?watch 与 watchEffect 的选型?为什么 computed 禁止副作用?
T2 · useRollingNumber 完整迁移(60 分钟)
完成第四节代码并在屏 B 占位页挂两个 KPI(产量、平均温度),用手动 setTimeout 改 source 值验证滚动效果与卸载清理(切换 v-show 卸载组件后,Performance 面板确认 rAF 归零)。
T3 · 告警 computed 链(40 分钟)
实现第五节派生链 + 一个"最新一条告警文本"的 computed,模板展示。故意在 computed 里 alarms.value.push(...) 体验报错,理解纯函数纪律。
八、总结
| 概念 | 要点 | 大屏对应 |
|---|---|---|
| computed | 派生、缓存、禁副作用 | 告警排序、KPI 派生 |
| watch | 显式源 + 回调,生产首选 | 数据到动画的联动 |
| watchEffect | 自动依赖,慎用 | 几乎不用 |
| 生命周期 | 成对纪律强制化为钩子结构 | resize/rAF/订阅清理 |
| 组合式函数 | use 开头、setup 顶层同步调用 | useRollingNumber |
逻辑层已就绪。明天把它装配进真正的组件:props / emit / slot 三件套。