【Vue3】day73-computed-watch

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

Day 73 · computed / watch / 生命周期 — 让状态自己流动起来

昨天学会了"存状态",今天学"状态之间的传导":computed(派生状态)、watch(联动副作用)、生命周期钩子(成对清理纪律)。实战对象是 Day 67 的 KPI 数字翻牌器——它恰好是三者的完美交汇:computed 算显示值,watch 触发滚动动画,onUnmounted 取消 rAF。今天产出第一个组合式函数 useRollingNumber


目录


一、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 是什么,不负责什么时候算

纪律:

  1. computed 里禁止副作用(不改状态、不操作 DOM)——它是"纯函数",混入副作用会让缓存机制变黑洞
  2. 能 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 三件套。

评论