05 · 数据与状态(Data & State)

📅 预计 80 分钟 | ⭐ = 高频考点 | 📌 中英术语见文末
✍️ 配套练习:网页练习 shengxia.dev/quiz?module=mv05


1.0 先给直觉:三个小工具的故事

  • 计算属性计算器:输入几个数,按下等号,立刻给你结果。结果还能被"记住"——只要输入没变,再按等号不用重新算。
  • 侦听器哨兵:你让它盯着某个值,这个值一变化,它立刻"吹哨子"通知你去做别的事。
  • 响应式系统自来水管道:你打开水龙头(读数据),水就来了;水厂一停水(数据变),所有龙头(视图)同步没水。管道自己负责通知,不用你挨家挨户敲门。

这三者一起构成了 Vue 处理数据的核心。理解它们,才算真正理解"数据驱动视图"是怎么落地的。

💡 记忆口诀:计算属性=算+缓存,侦听器=盯+通知,响应式=管道+自动同步。


1.1 计算属性:computed ⭐

场景:模板里的"中间结果"

页面里经常需要"基于已有数据算出一个新值"。比如购物车要算总价:

<p>总价:{{ items.reduce((sum, i) => sum + i.price, 0) }} 元</p>

这串表达式又长又难读,而且可能在多个地方重复。把计算"搬进"计算属性:

<template>
  <p>总价:{{ totalPrice }} 元</p>
  <p>优惠后:{{ totalPrice * 0.9 }} 元</p>   <!-- 复用计算结果 -->
</template>

<script>
export default {
  data() {
    return {
      items: [
        { name: '书', price: 30 },
        { name: '笔', price: 5 },
      ],
    };
  },
  computed: {
    totalPrice() {
      // 它依赖 items,items 变了它自动重新计算
      return this.items.reduce((sum, i) => sum + i.price, 0);
    },
  },
};
</script>

计算属性的两个特性 ⭐

  1. 缓存(cached):只要依赖的数据没变,多次读取 totalPrice 直接返回上次结果,不会重复执行函数。这是计算属性与"普通方法调用"的最大区别——普通方法每次调用都重新跑一遍。
  2. 响应式依赖追踪:它自动"记住"自己读了哪些数据(这里是 items),这些数据一变,它自动重新计算,视图同步更新。

计算属性 vs 方法

计算属性 computed 方法 methods
是否缓存 依赖不变就缓存结果 每次调用都执行
使用方式 当属性用 {{ totalPrice }} 当函数调用 {{ getTotal() }}
适合 多次复用、依赖数据的计算结果 每次都要最新、或有副作用

原则:计算属性里不要写有副作用的代码(如修改其他数据、发请求),它应该"纯计算"——同样的输入永远同样的输出。

⚠️ 常见错误

  1. 计算属性里改其他数据totalPrice() { this.items.push(...); return ... },依赖循环还难调试,计算属性应保持纯函数。
  2. 把不需要缓存的也做成计算属性:读随机数 Math.random(),缓存了反而一直不变,应该用方法。
  3. 在计算属性里发异步请求:计算属性是同步求值,异步请求结果没法"算"出来,该放侦听器或方法。

1.2 侦听器:watch ⭐

场景:值变了,要"做点什么"

计算属性负责"算结果",侦听器(watch)负责"值一变,去执行副作用"。典型场景:数据变化后发请求、写本地存储、更新标题:

<script>
export default {
  data() {
    return { keyword: '' };
  },
  watch: {
    // 监听 keyword:它一变,这个函数就执行
    keyword(newValue, oldValue) {
      console.log(`关键词从 ${oldValue} 变为 ${newValue}`);
      this.search(newValue);      // 触发副作用:发起搜索请求
    },
  },
  methods: {
    search(k) {
      // 模拟请求
      console.log('搜索:', k);
    },
  },
};
</script>

监听函数接收两个参数:新值旧值。适合处理"数据变化后要做的动作"。

深度侦听与立即执行

  • 监听对象内部属性:直接 watch: { user.name() {...} } 可以监听对象属性;要监听整个对象内部变化需 deep: true(开销较大,慎用)。
  • 立即执行:默认监听在"值变化后"才触发,第一次不会执行。加 immediate: true 让它在组件创建时立刻跑一次。
watch: {
  user: {
    handler(newVal) { console.log('user 变化了', newVal); },
    deep: true,        // 深度监听:user 内部字段变了也触发
    immediate: true,   // 创建时就执行一次
  },
},

计算属性 vs 侦听器怎么选 ⭐

  • 算一个新值给模板用 → 计算属性(缓存、声明式)。
  • 值变化后做一件事(请求、存储、DOM 操作)→ 侦听器。
  • 很多人习惯"能不用 watch 就不用",优先计算属性;watch 用于"变化后执行动作"的副作用场景。

⚠️ 常见错误

  1. 用 watch 去算派生值:想显示 fullName = firstName + lastName 却用 watch 改另一个 data,绕远且不直观,计算属性一步到位。
  2. deep: true 滥用:大对象深度监听性能差,尽量监听具体属性。
  3. 在 watch 里同步改自己:监听 keyword 又在 handler 里改 keyword,触发无限循环。

1.3 响应式原理:Vue 怎么知道"变了" ⭐

一句话核心

响应式(reactivity)是 Vue 的核心机制:让"数据"具备通知能力——被读取时记下谁在依赖它,被修改时通知所有依赖方重新计算。这正是"水管道"的实现。

Vue 3:Proxy 代理

Vue 3 用 ES6 的 Proxy 把整个数据对象包起来,拦截对属性的读取(get)写入(set)

const raw = { count: 0 };
const state = new Proxy(raw, {
  get(target, key) {
    // 读取时:把当前"正在计算谁"登记为依赖
    track(target, key);
    return target[key];
  },
  set(target, key, value) {
    target[key] = value;
    // 写入时:通知所有依赖这个 key 的地方"数据变了"
    trigger(target, key);
    return true;
  },
});

机制就两件事:

  • 收集依赖(track):读某个属性时,记录"谁在用这个属性"(哪个组件、哪个计算属性)。
  • 派发更新(trigger):改某个属性时,通知所有依赖方"重新渲染/重新计算"。

于是 state.count++ 一句,所有读到 count 的视图都自动更新。

Vue 2 的局限(了解)

Vue 2 用的是 Object.defineProperty,只能拦截已经存在的属性,新增属性、按下标改数组都检测不到,需要 Vue.set 等补救。Vue 3 的 Proxy 天然支持新增、删除、数组操作,这也是 Vue 3 的重要改进。

约束:依赖要能"被读到"

响应式依赖必须在渲染/计算过程中被读取才有效。setTimeout、异步回调里读取的属性,Vue 收集不到依赖,变化时不会更新。

⚠️ 常见错误

  1. 在 Vue 3 里按 Vue 2 的习惯踩坑:以为"新增属性不响应",用 Vue.set,Vue 3 里直接赋值即可。
  2. 异步后读数据不更新:在 setTimeout 里才读取某属性,它不在渲染依赖里,改了不刷新。
  3. 解构丢失响应式const { count } = statecount 只是普通值,不再响应;要用 toRefs 处理。

1.4 状态管理:多组件共享数据 ⭐

痛点:跨组件共享越来越乱

组件通信的方式有很多:props 传数据、事件报行为、插槽填内容。但当一个数据(比如"当前登录用户")被很多不相关的组件使用,一层层传 props 会变成"钻地洞"——中间组件只是路过也要带一程,代码又长又乱,这就是 props 逐层传递问题。

方案:把共享状态抽出来

**状态管理(state management)**把"多个组件要用的状态"集中到一处,任何组件都能直接读、直接改。Vue 官方方案是 Pinia(旧版是 Vuex)。

核心概念:

  • state(状态):集中存放的数据,单一数据源。
  • getters(读取):类似组件的计算属性,基于 state 派生值。
  • actions(动作):改变状态的方法,集中写业务逻辑(含异步)。
// store.js(Pinia 写法)
import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({
    name: '',
    loggedIn: false,
  }),
  getters: {
    greeting: (state) => (state.loggedIn ? `你好,${state.name}` : '请登录'),
  },
  actions: {
    login(name) {
      this.name = name;
      this.loggedIn = true;
    },
    logout() {
      this.name = '';
      this.loggedIn = false;
    },
  },
});

任意组件里都能用:

import { useUserStore } from './store';
const store = useUserStore();
store.login('小张');              // 改状态,全站用到它的地方都更新

单一数据源(single source of truth):同一个"用户"只有一份状态,所有组件读到的是同一份,不会各存各的导致不一致。

什么时候才需要状态管理 ⭐

  • 多个不相关组件共享数据:全局用户、购物车、主题设置。
  • 组件层级很深:深层组件要拿顶层数据,传 props 太痛苦。
  • 别过度设计:只有两个组件通信,用 props + 事件就够,为小项目引入全局状态反而增加心智负担。

⚠️ 常见错误

  1. 把所有状态都塞进 store:局部状态(一个组件自己用的临时值)也全局化,store 膨胀,可读性差。
  2. 在 store 里放非共享数据:每个组件都有自己的 data,共享的才进 store。
  3. 异步逻辑散落在组件:应该集中在 actions,方便复用和测试。
  4. 绕过 getters 直接依赖 state 变形:多处组件自己写同样的派生逻辑,容易不一致,统一用 getters。

1.5 组件通信方式小结

场景 方式
父 → 子 props
子 → 父 $emit 事件
父 → 子内容 插槽 slot
祖先 → 深层后代 provide / inject(提供注入)
多个不相关组件 状态管理(Pinia)

provide / inject 适合"祖先组件给深层后代传数据,中间组件不参与"的场景,但要克制使用,过度使用会让数据来源不清晰。

选型的核心思路

先问:"数据属于谁?" 属于单个组件 → 留在 data;属于父子 → props/事件;属于很多互不相干的组件 → 状态管理。数据该放哪,就放哪,不硬套某种方案。

⚠️ 常见错误

  1. 能用 props 解决的非要引入全局 store:小通信上大工具,代码反而绕。
  2. provide/inject 滥用:来源不明,排查问题要翻多层,尽量少用。
  3. 不清楚数据归属:一份状态既在组件 data 又放 store,两处都在改,必然不一致。

1.6 状态管理的边界与调试

什么该进 store,什么该留 data

这是个反复出现的问题,给出一组实用的判断标准:

  • 多个组件要用 → 进 store(登录用户、主题、购物车)。
  • 只在单个组件内部用 → 留在 data(一个弹窗的开关、输入框草稿)。
  • 临时计算值 → 留在组件内的计算属性,不必进 store。

口诀是"共享才集中,局部就留着"。把无关数据塞进全局 store,store 会变成一个"什么都装"的大抽屉,谁都不敢乱动。

按领域拆分 store

不要在单个 store 里装所有状态。按业务领域拆成多个 store:useUserStore 管用户、useCartStore 管购物车、useThemeStore 管主题。每个 store 小而专,调用方按需引入。

调试:响应式数据为什么没更新

数据改了视图没动,是新手最常遇到的困惑。按这个顺序排查:

  1. 是否真的改了响应式数据this.items = newArray 是替换,能触发更新;在 Vue 3 中直接 this.items.push() 也可以。先确认数据本体确实变了。
  2. 是否在渲染依赖之外读取:只有渲染或计算过程中读取过的属性才会被收集依赖;setTimeout 里才读的属性不触发更新。
  3. 是否绕过状态直接改 DOMdocument.querySelector(...).style = ... 改的是 DOM 不是数据,下次状态更新会覆盖它。

开发工具辅助

Vue Devtools 能在浏览器里实时查看状态、追踪每次修改的来源。遇到"数据不知被谁改了"时,打开 Devtools 看时间线是最快的定位手段——这也是"数据驱动"工程化里的标准调试姿势。

⚠️ 常见错误

  1. 所有状态一股脑进 store:局部状态全局化,store 膨胀,依赖混乱。
  2. 以为改状态就要"刷新页面":响应式系统会自动更新,手动刷新反而掩盖了真正的逻辑问题。
  3. 排查"视图没更新"时盯着模板:大概率是数据没按预期变,先验证数据,再查模板。
  4. store 之间相互引用太多:A store 依赖 B,B 又依赖 A,形成循环,设计上应保持 store 相对独立。

1.7 模板引用 ref:在"数据驱动"之外的操作入口

什么时候需要直接摸 DOM

MVVM 主张"不直接操作 DOM",但少数场景绕不开:聚焦输入框、测量元素尺寸、调用第三方库。Vue 提供 **ref(模板引用)**作为官方入口——给元素打上标记,之后就能拿到真实 DOM:

<template>
  <input ref="inputBox" placeholder="请输入" />
  <button @click="focusInput">聚焦</button>
</template>

<script>
export default {
  methods: {
    focusInput() {
      this.$refs.inputBox.focus();   // 拿到真实 input 元素并聚焦
    },
  },
};
</script>

ref="xxx" 标记元素,组件里用 this.$refs.xxx 拿到它。注意:**只有在组件挂载后(mounted)**才能安全读取 $refscreated 阶段元素还不存在。

与状态管理的分工

ref 是"特殊情况下的后门",不是日常工具。能用数据驱动解决的就用数据驱动,只有那些没有数据概念的操作用 ref。一句话区分:

  • 和状态有关(文字内容、样式、显示隐藏)→ 用数据。
  • 和状态无关的浏览器操作(聚焦、滚动位置、测量尺寸)→ 用 ref

⚠️ 常见错误

  1. created 里读 $refs:此时 DOM 未挂载,拿到的是 undefined;要等到 mounted
  2. 把状态相关的东西也走 ref:本可以用 v-model 的数据却手动改 DOM,绕开响应式,数据与显示脱节。
  3. v-for 的每一项都打 ref 还指望拿到单个元素$refs 会是数组,需要按索引取。

📌 记忆口诀

  • 计算属性:算完缓存,依赖不变不重算;纯计算,无副作用。
  • 侦听器:盯着值,一变就执行动作;算派生值别用它。
  • 响应式:读取收集依赖,写入派发更新,管道自动同步。
  • 状态管理:共享数据集中放,单一数据源,改一处全更新。
  • 通信选型:父子用 props/事件,跨多组件用 store。

📌 双语术语表(本讲)

中文 English 记忆点
计算属性 computed 带缓存的计算结果
侦听器 watcher 值变化后执行动作
响应式 reactivity 数据自动驱动视图
依赖收集 track 读取时登记依赖
派发更新 trigger 写入时通知更新
代理 proxy Vue 3 的拦截手段
状态管理 state management 集中管理共享状态
单一数据源 single source of truth 状态只有一份
提供 / 注入 provide / inject 祖先传给深层后代

⭐ 本讲考点清单

  1. 计算属性:缓存特性、响应式依赖、纯计算原则
  2. 计算属性 vs 方法 vs 侦听器的选择
  3. 侦听器参数(新值、旧值)、deep / immediate 选项
  4. Vue 3 响应式基于 Proxy:get 收集依赖、set 派发更新
  5. Vue 2(defineProperty)与 Vue 3(Proxy)的差异
  6. 状态管理解决什么问题、何时需要
  7. Pinia 核心:state / getters / actions
  8. 组件通信方式总览与选型思路
  9. 单一数据源的意义