MV-05 数据与状态
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>
计算属性的两个特性 ⭐
- 缓存(cached):只要依赖的数据没变,多次读取
totalPrice直接返回上次结果,不会重复执行函数。这是计算属性与"普通方法调用"的最大区别——普通方法每次调用都重新跑一遍。 - 响应式依赖追踪:它自动"记住"自己读了哪些数据(这里是
items),这些数据一变,它自动重新计算,视图同步更新。
计算属性 vs 方法
| 计算属性 computed | 方法 methods | |
|---|---|---|
| 是否缓存 | 依赖不变就缓存结果 | 每次调用都执行 |
| 使用方式 | 当属性用 {{ totalPrice }} |
当函数调用 {{ getTotal() }} |
| 适合 | 多次复用、依赖数据的计算结果 | 每次都要最新、或有副作用 |
原则:计算属性里不要写有副作用的代码(如修改其他数据、发请求),它应该"纯计算"——同样的输入永远同样的输出。
⚠️ 常见错误
- 计算属性里改其他数据:
totalPrice() { this.items.push(...); return ... },依赖循环还难调试,计算属性应保持纯函数。 - 把不需要缓存的也做成计算属性:读随机数
Math.random(),缓存了反而一直不变,应该用方法。 - 在计算属性里发异步请求:计算属性是同步求值,异步请求结果没法"算"出来,该放侦听器或方法。
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 用于"变化后执行动作"的副作用场景。
⚠️ 常见错误
- 用 watch 去算派生值:想显示
fullName = firstName + lastName却用 watch 改另一个 data,绕远且不直观,计算属性一步到位。 deep: true滥用:大对象深度监听性能差,尽量监听具体属性。- 在 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 收集不到依赖,变化时不会更新。
⚠️ 常见错误
- 在 Vue 3 里按 Vue 2 的习惯踩坑:以为"新增属性不响应",用
Vue.set,Vue 3 里直接赋值即可。 - 异步后读数据不更新:在
setTimeout里才读取某属性,它不在渲染依赖里,改了不刷新。 - 解构丢失响应式:
const { count } = state后count只是普通值,不再响应;要用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 + 事件就够,为小项目引入全局状态反而增加心智负担。
⚠️ 常见错误
- 把所有状态都塞进 store:局部状态(一个组件自己用的临时值)也全局化,store 膨胀,可读性差。
- 在 store 里放非共享数据:每个组件都有自己的 data,共享的才进 store。
- 异步逻辑散落在组件:应该集中在 actions,方便复用和测试。
- 绕过 getters 直接依赖 state 变形:多处组件自己写同样的派生逻辑,容易不一致,统一用 getters。
1.5 组件通信方式小结
| 场景 | 方式 |
|---|---|
| 父 → 子 | props |
| 子 → 父 | $emit 事件 |
| 父 → 子内容 | 插槽 slot |
| 祖先 → 深层后代 | provide / inject(提供注入) |
| 多个不相关组件 | 状态管理(Pinia) |
provide / inject 适合"祖先组件给深层后代传数据,中间组件不参与"的场景,但要克制使用,过度使用会让数据来源不清晰。
选型的核心思路
先问:"数据属于谁?" 属于单个组件 → 留在 data;属于父子 → props/事件;属于很多互不相干的组件 → 状态管理。数据该放哪,就放哪,不硬套某种方案。
⚠️ 常见错误
- 能用 props 解决的非要引入全局 store:小通信上大工具,代码反而绕。
provide/inject滥用:来源不明,排查问题要翻多层,尽量少用。- 不清楚数据归属:一份状态既在组件 data 又放 store,两处都在改,必然不一致。
1.6 状态管理的边界与调试
什么该进 store,什么该留 data
这是个反复出现的问题,给出一组实用的判断标准:
- 多个组件要用 → 进 store(登录用户、主题、购物车)。
- 只在单个组件内部用 → 留在 data(一个弹窗的开关、输入框草稿)。
- 临时计算值 → 留在组件内的计算属性,不必进 store。
口诀是"共享才集中,局部就留着"。把无关数据塞进全局 store,store 会变成一个"什么都装"的大抽屉,谁都不敢乱动。
按领域拆分 store
不要在单个 store 里装所有状态。按业务领域拆成多个 store:useUserStore 管用户、useCartStore 管购物车、useThemeStore 管主题。每个 store 小而专,调用方按需引入。
调试:响应式数据为什么没更新
数据改了视图没动,是新手最常遇到的困惑。按这个顺序排查:
- 是否真的改了响应式数据:
this.items = newArray是替换,能触发更新;在 Vue 3 中直接this.items.push()也可以。先确认数据本体确实变了。 - 是否在渲染依赖之外读取:只有渲染或计算过程中读取过的属性才会被收集依赖;
setTimeout里才读的属性不触发更新。 - 是否绕过状态直接改 DOM:
document.querySelector(...).style = ...改的是 DOM 不是数据,下次状态更新会覆盖它。
开发工具辅助
Vue Devtools 能在浏览器里实时查看状态、追踪每次修改的来源。遇到"数据不知被谁改了"时,打开 Devtools 看时间线是最快的定位手段——这也是"数据驱动"工程化里的标准调试姿势。
⚠️ 常见错误
- 所有状态一股脑进 store:局部状态全局化,store 膨胀,依赖混乱。
- 以为改状态就要"刷新页面":响应式系统会自动更新,手动刷新反而掩盖了真正的逻辑问题。
- 排查"视图没更新"时盯着模板:大概率是数据没按预期变,先验证数据,再查模板。
- 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)**才能安全读取 $refs,created 阶段元素还不存在。
与状态管理的分工
ref 是"特殊情况下的后门",不是日常工具。能用数据驱动解决的就用数据驱动,只有那些没有数据概念的操作用 ref。一句话区分:
- 和状态有关(文字内容、样式、显示隐藏)→ 用数据。
- 和状态无关的浏览器操作(聚焦、滚动位置、测量尺寸)→ 用
ref。
⚠️ 常见错误
- 在
created里读$refs:此时 DOM 未挂载,拿到的是 undefined;要等到mounted。 - 把状态相关的东西也走 ref:本可以用
v-model的数据却手动改 DOM,绕开响应式,数据与显示脱节。 - 对
v-for的每一项都打 ref 还指望拿到单个元素:$refs会是数组,需要按索引取。
📌 记忆口诀
- 计算属性:算完缓存,依赖不变不重算;纯计算,无副作用。
- 侦听器:盯着值,一变就执行动作;算派生值别用它。
- 响应式:读取收集依赖,写入派发更新,管道自动同步。
- 状态管理:共享数据集中放,单一数据源,改一处全更新。
- 通信选型:父子用 props/事件,跨多组件用 store。
📌 双语术语表(本讲)
| 中文 | English | 记忆点 |
|---|---|---|
| 计算属性 | computed | 带缓存的计算结果 |
| 侦听器 | watcher | 值变化后执行动作 |
| 响应式 | reactivity | 数据自动驱动视图 |
| 依赖收集 | track | 读取时登记依赖 |
| 派发更新 | trigger | 写入时通知更新 |
| 代理 | proxy | Vue 3 的拦截手段 |
| 状态管理 | state management | 集中管理共享状态 |
| 单一数据源 | single source of truth | 状态只有一份 |
| 提供 / 注入 | provide / inject | 祖先传给深层后代 |
⭐ 本讲考点清单
- 计算属性:缓存特性、响应式依赖、纯计算原则
- 计算属性 vs 方法 vs 侦听器的选择
- 侦听器参数(新值、旧值)、
deep/immediate选项 - Vue 3 响应式基于 Proxy:get 收集依赖、set 派发更新
- Vue 2(defineProperty)与 Vue 3(Proxy)的差异
- 状态管理解决什么问题、何时需要
- Pinia 核心:state / getters / actions
- 组件通信方式总览与选型思路
- 单一数据源的意义