Vue2 计算属性深度解析:缓存机制、响应式追踪与实战模式
导读:本文以 Vue2 的计算属性(computed)为核心,从惰性求值、依赖收集、Watcher 机制等底层原理展开,对比 methods / watch 三者的本质差异与适用场景,并通过反转字符串、可读写计算属性、购物车综合案例等完整可运行示例,帮助读者真正理解"缓存"与"响应式"背后的设计哲学。参考了 Vue2 官方文档 及 Vue 响应式系统源码层面的分析。适合已掌握 Vue 基础指令、希望深入理解 Vue 响应式机制的前端开发者。
目录
- 零、导读与学习价值
- 0.1 示例覆盖清单
- 0.2 核心名词速查
- 0.3 为什么要学本篇
- 一、计算属性 computed 的本质
- 1.1 从插值表达式的痛点说起
- 1.2 惰性求值与缓存机制
- 1.3 反转字符串完整示例
- 二、computed 的 get/set 双向计算
- 2.1 完整对象写法
- 2.2 可读写计算属性实战示例
- 三、computed 底层原理:Watcher 与依赖收集
- 3.1 响应式系统三个核心角色
- 3.2 依赖收集全流程
- 3.3 computed Watcher 与 lazy 标志
- 四、computed vs methods vs watch 深度对比
- 4.1 三者的本质差异
- 4.2 watch 侦听器详解
- 4.3 选择决策树
- 五、购物车综合案例:computed 的实战价值
- 5.1 需求分析
- 5.2 完整购物车实现
- 六、v-cloak 与渲染闪烁问题
- 七、computed 的进阶模式
- 7.1 多依赖聚合
- 7.2 computed 的常见误区
- 总结
- 知识点回顾(思维导图)
- 高频面试题速查
- 学习建议
零、导读与学习价值
0.1 示例覆盖清单
| 反转字符串 | computed 基本函数写法、缓存验证 |
| get/set 双向计算 | computed 完整对象写法、setter 触发响应 |
| 可读写计算属性 | 双向绑定 v-model 绑定到 computed |
| 购物车总价计算 | computed 聚合多个 data 属性,filter/reduce 实战 |
| 购物车折扣与优惠 | computed 链式依赖,数字格式化 |
| v-cloak 闪烁解决 | 属性选择器 + CSS 隐藏,挂载完成后移除属性 |
| methods 对比 | 无缓存多次调用、this 指向差异 |
| watch 侦听器 | 异步场景、deep / immediate 选项 |
0.2 核心名词速查
| computed | Vue 配置对象中的计算属性,本质是带缓存的 getter/setter 函数 |
| 依赖(Dep) | 每个响应式属性对应一个 Dep 实例,维护订阅该属性的 Watcher 列表 |
| Watcher | 观察者对象,订阅依赖变化后执行回调(组件渲染/computed 重算/watch 回调) |
| dirty 标志 | computed Watcher 的「脏位」,为 true 表示需要重算,false 表示可用缓存 |
| 惰性求值(lazy) | computed 不立即计算,只有被访问时才执行 getter |
| 依赖收集 | getter 执行时,将当前活跃的 Watcher 加入 Dep.subs 的过程 |
| 派发更新 | 数据 setter 触发 dep.notify(),通知所有订阅者更新 |
| v-cloak | 挂载完成前标记元素,配合 CSS 隐藏插值表达式的「花括号闪烁」问题 |
| watch | 侦听器,用于在数据变化时执行副作用(如异步请求、日志) |
0.3 为什么要学本篇
一、计算属性 computed 的本质
1.1 从插值表达式的痛点说起
名词解释
- 插值表达式:{{ }} 语法,在 Vue 模板中执行 JavaScript 表达式并渲染结果。
- 模板臃肿:当逻辑过于复杂时,插值表达式让模板难以阅读和维护。
- 重复计算:相同的表达式在模板中多次出现,每次渲染都会重新执行,没有任何缓存。
概念与底层原理
Vue 模板本质上会被编译成渲染函数(render function)。每当响应式数据变化,渲染函数重新执行,所有插值表达式中的 JavaScript 表达式也随之重新求值。如果同一个表达式在模板中出现 5 次,它就会被执行 5 次。
<!– 模板臃肿、重复计算的问题示例 –>
<p>{{ str.split('').reverse().join('') }}</p>
<p>{{ str.split('').reverse().join('') }}</p>
<p>{{ str.split('').reverse().join('') }}</p>
【代码注释】这段模板片段演示了插值表达式的痛点:相同的计算逻辑 split().reverse().join() 在模板中重复出现 3 次,每次渲染都会各自执行一遍。如果 str 频繁变化(如输入框实时响应),或者计算逻辑更复杂(如过滤上千条数据),性能损耗会成倍放大。市面应用:实际项目中见到模板里出现超过一次的表达式,几乎都应该抽到 computed 中。
三个完全相同的表达式,每次渲染执行 3 次。对于复杂计算(比如过滤千条数据),这个性能损耗不可忽视。
methods 的局限
将逻辑抽取到 methods 可以解决模板臃肿,但无法解决重复执行的问题:
<p>{{ reverseFn() }}</p>
<p>{{ reverseFn() }}</p>
<p>{{ reverseFn() }}</p>
【代码注释】将逻辑提取到 methods 之后模板变得清晰,但性能问题依然存在:模板调用 reverseFn() 3 次,函数就执行 3 次。更棘手的是,即便 str 没有变化,只要其他任何响应式数据(如 data.count)发生变化导致整个组件重渲染,reverseFn() 也会被重新调用——methods 在渲染 Watcher 的视角里只是「普通函数」,没有任何缓存机制。市面应用:methods 最适合用于事件处理函数,或确实需要传参的场景;纯粹的数据派生应当用 computed 替代。
模板中调用方法 3 次,函数就执行 3 次。更关键的是:即便 str 没有变化,只要其他任何响应式数据变化导致重新渲染,reverseFn() 也会再次执行。
computed 的解决方案
计算属性在 Vue 配置对象的 computed 选项中定义。它的核心特点是:
官方文档参考:Vue2 计算属性

【代码注释】此流程图展示了计算属性的缓存机制核心:dirty 标志控制是否重新执行 getter。蓝色「模板访问」进入黄色菱形判断——dirty 为 true(脏)时走绿色路径执行 getter、读取 this.str、把结果写入 value 缓存并置 dirty=false;为 false(缓存命中)时直接返回缓存值,跳过整段计算。右侧橙色链路是关键:str 变化触发 setter → dep.notify() → computed Watcher 的 update() 只把 dirty 置回 true 而不立即重算(这正是 lazy watcher 与渲染 watcher 的本质区别,渲染 watcher 会直接入队重渲染)。只有下一次有人访问该属性时才真正 evaluate()。底层依据:Vue.js 技术揭秘 – 计算属性 VS 侦听属性 指出 computed watcher 的 update 在 lazy 为真时仅执行 this.dirty = true,把重算延迟到 evaluate() 被调用时——这就是「惰性求值 + 脏检查缓存」的全部机制。市面应用:这套「标记脏位、按需重算」是 MobX computed、Vue3 computed()、SolidJS createMemo 的共同范式。
1.2 惰性求值与缓存机制
惰性求值(Lazy Evaluation)
与 methods 的即时执行不同,computed Watcher 在创建时设置了 lazy: true,意味着它不会立即执行 getter,而是等到第一次有人读取这个计算属性时才执行。
缓存机制的实现
Vue 内部用 dirty 布尔标志实现缓存判断:
初始状态:dirty = true(需要计算)
↓
第一次访问:执行 getter,缓存结果,dirty = false
↓
再次访问:dirty = false,直接返回缓存值,不执行 getter
↓
依赖变化(str setter 触发):dirty = true(标记需要重算)
↓
下次访问:执行 getter,更新缓存,dirty = false
【代码注释】这段伪代码展示了 dirty 标志的状态机转换。dirty 只有两个值:true(脏,需要重算)和 false(干净,使用缓存)。初始值为 true 确保第一次访问时必然执行 getter;执行后置 false 让后续访问命中缓存;依赖数据变化时重置为 true 使下次访问重新计算。这是一个极简但高效的「按需重算」状态机,是 Vue computed 缓存的全部秘密。市面应用:这种「标记脏位」模式在 MobX、Svelte、SolidJS 等框架中都有对应实现,是响应式系统的通用优化手段。
这个机制保证了:无论模板中引用了计算属性多少次,getter 只在依赖真正变化后才重新执行一次。
1.3 反转字符串完整示例
下面的示例对比了插值表达式、methods、过滤器、computed 四种实现方式,并通过 console.log 直接验证计算属性的缓存特性。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>computed 缓存验证</title>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<style>
body { font-family: sans-serif; padding: 20px; }
.section { border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin: 12px 0; }
h4 { color: #3b82f6; margin: 0 0 8px; }
input { padding: 6px 10px; border: 1px solid #cbd5e1; border-radius: 4px; width: 240px; }
p { margin: 4px 0; color: #334155; }
</style>
</head>
<body>
<div id="app">
<input type="text" v-model="str" placeholder="输入文字观察各方式的差异">
<div class="section">
<h4>① 插值表达式(每次渲染重复执行)</h4>
<p>{{ str.split('').reverse().join('') }}</p>
<p>{{ str.split('').reverse().join('') }}</p>
</div>
<div class="section">
<h4>② methods(每次调用都执行,无缓存)</h4>
<p>{{ reverseFn() }}</p>
<p>{{ reverseFn() }}</p>
</div>
<div class="section">
<h4>③ computed(多次引用只执行一次 getter)</h4>
<p>{{ reverseCom }}</p>
<p>{{ reverseCom }}</p>
<p>{{ reverseCom }}</p>
</div>
</div>
<script>
new Vue({
el: '#app',
data: {
str: '你好,Vue!'
},
methods: {
reverseFn() {
// 每次调用都会执行,打开控制台观察日志次数
console.log('[methods] reverseFn 被调用');
return this.str.split('').reverse().join('');
}
},
computed: {
reverseCom() {
// 依赖(str)未变时,无论引用多少次,这里只执行一次
console.log('[computed] reverseCom getter 执行');
return this.str.split('').reverse().join('');
}
}
});
</script>
</body>
</html>
【代码注释】打开浏览器控制台,每次修改输入框时观察日志:[methods] reverseFn 被调用 会出现 2 次(模板调用了 2 次),而 [computed] reverseCom getter 执行 只出现 1 次(模板引用了 3 次,但缓存命中后不重复执行)。这直接证明了 computed 的缓存特性。市面应用:电商商品列表中的价格格式化、筛选后的商品总数等,用 computed 而非 methods 可显著减少计算次数。
【实战要点】
- 经典应用场景:computed 适用于「从已有 data 派生新值」的场景。例如,从商品列表中筛选出库存大于 0 的商品、根据用户输入实时计算搜索结果数量、组合 firstName + lastName 得到 fullName。
- 常见坑:在 computed getter 中执行异步操作(如 setTimeout、axios.get)是无效的——getter 的返回值会立即被 Vue 使用,异步回调中的返回值根本不会被捕获。异步场景应改用 watch。
- 性能特征:computed 的缓存来自 dirty 标志判断,这个判断本身的开销几乎为零。只有当依赖变化且下次访问时才重新计算,是性能最优的数据派生方式。
【本章小结】
| 插值表达式 | 无 | 无 | 否 | 简单、一次性、不复用的表达式 |
| methods | 无 | 可传 | 否 | 需要传参、事件处理、副作用操作 |
| computed | 有 | 不可传 | 可(set) | 基于现有数据派生新值,可复用 |
| watch | — | — | — | 异步操作、数据变化时执行副作用 |
记忆口诀:「派生取值用 computed(有缓存),传参办事用 methods(无缓存),一次性简单写插值」。
【面试考点】
Q1:为什么 computed 有缓存而 methods 没有?
A:根本原因在于 Vue 对二者的处理方式不同。methods 在模板中被当作普通函数调用,每次渲染函数执行都会调用它。而 computed 由 Vue 创建了一个带有 lazy: true 的 Watcher,其 dirty 标志控制是否重新执行 getter:初始为 true(需要计算),执行完后置 false(使用缓存);只有依赖的响应式数据触发 setter 时,dep.notify() 才将 dirty 重置为 true。因此同一依赖下,无论模板中访问计算属性多少次,getter 只执行一次。
二、computed 的 get/set 双向计算
2.1 完整对象写法
名词解释
- 简写形式:computed 中的属性值直接写一个函数,Vue 将其作为 getter。
- 完整对象形式:属性值是包含 get 和 set 两个方法的对象,使计算属性既可读也可写。
- setter:当外部(包括 v-model)试图修改计算属性时,Vue 调用 set(newValue) 方法。
概念与底层原理
计算属性的简写形式实际上只定义了 getter:
// 简写(只读)
computed: {
reverseCom() {
return this.str.split('').reverse().join('');
}
}
// 等价于完整写法的只读版本:
computed: {
reverseCom: {
get() {
return this.str.split('').reverse().join('');
}
}
}
【代码注释】两种写法在功能上完全等价:函数简写形式是对象写法的语法糖,Vue 在内部会统一处理成带 get 的对象形式。函数简写的场景是「只需要读取派生值,不需要写入」——这也是 80% 以上的 computed 用例。理解两者的等价关系有助于在需要 setter 时直接切换写法,而不需要重构其他代码。市面应用:Vue CLI / Vite 项目中,ESLint 规则 vue/no-computed-set-without-get 会强制要求 setter 必须和 getter 配对出现,防止逻辑缺失。
当需要「写入计算属性来反向更新原始数据」时,就需要 setter:
computed: {
reverseCom: {
get() {
// 读取时:从 str 正向计算
return this.str.split('').reverse().join('');
},
set(newValue) {
// 写入时:将新值反向写回 str
this.str = newValue.split('').reverse().join('');
}
}
}
【代码注释】get() 和 set() 是 Vue computed 对象写法的两个钩子。get() 返回值即计算属性的「读取结果」;set(newValue) 在外部给该 computed 属性赋值时触发,newValue 是被赋的新值。这里 set 将新值反转后写回 this.str——注意 set 不能直接修改 reverseCom 本身(计算属性没有独立存储),只能通过修改底层 data 来间接影响 get 的返回值。市面应用:v-model 绑定 computed 是 Vue 表单组件封装的核心技巧,常见于二次封装的 el-input、日期选择器等组件。
当模板中出现 <input v-model="reverseCom"> 时:
- 用户读取输入框的值 → 触发 get()
- 用户修改输入框的值 → 触发 set(newValue) → 更新 str → str 变化 → get() 重算 → 视图同步更新
这形成了一个完整的双向数据流,而背后控制的数据源始终是 str。

【代码注释】这个流程图展示了 v-model 绑定 computed 时的完整数据流,按编号顺序追踪一次双向更新。关键在于:紫色的 set 方法不是直接修改计算属性本身的值(计算属性没有自己的存储槽位),而是修改绿色的底层 data.str;橙色第 4 步是核心——str 的 setter 触发 dep.notify(),间接让 get() 在下次访问时重算,从而完成响应式更新闭环。理解这一点就能解释为什么「计算属性必须有可写的底层 data 才能配 setter」——setter 本身没有存储,它只是把新值"翻译"成对原始数据的写入。市面应用:二次封装的表单组件(如格式化输入框、范围选择器)几乎都用「computed get/set + v-model」承接父子值同步。
2.2 可读写计算属性实战示例
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>computed get/set 双向计算</title>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<style>
body { font-family: sans-serif; padding: 20px; max-width: 600px; }
.card { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 20px; margin: 12px 0; }
label { display: block; margin-bottom: 6px; color: #64748b; font-size: 14px; }
input { padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px; width: 100%; box-sizing: border-box; }
.display { padding: 10px 14px; background: #dbeafe; border-radius: 6px; color: #1e3a8a; font-weight: 500; }
h3 { color: #1e293b; }
</style>
</head>
<body>
<div id="app">
<h3>姓名双向绑定示例</h3>
<div class="card">
<label>原始数据 – 姓(firstName)</label>
<input v-model="firstName" placeholder="输入姓">
<label style="margin-top:12px">原始数据 – 名(lastName)</label>
<input v-model="lastName" placeholder="输入名">
</div>
<div class="card">
<label>computed 全名(读写均可)</label>
<input v-model="fullName" placeholder="也可直接输入全名(空格分隔)">
<p style="margin-top:8px">显示:<span class="display">{{ fullName }}</span></p>
</div>
</div>
<script>
new Vue({
el: '#app',
data: {
firstName: '张',
lastName: '三'
},
computed: {
fullName: {
// get:从 firstName + lastName 合并
get() {
return this.firstName + ' ' + this.lastName;
},
// set:将「姓 名」格式的字符串拆分回 firstName 和 lastName
set(newValue) {
const parts = newValue.trim().split(/\\s+/);
this.firstName = parts[0] || '';
this.lastName = parts.slice(1).join(' ') || '';
}
}
}
});
</script>
</body>
</html>
【代码注释】fullName 的 get() 将 firstName 和 lastName 拼接为全名;set(newValue) 通过空格分割将用户输入拆分回两个独立字段。/\\s+/ 正则匹配一个或多个空白符,兼容多空格输入。市面应用:这种「合并字段 ↔ 拆分存储」模式在用户个人资料表单中极为常见;还常用于「受控的格式化输入」——比如电话号码以 138 0000 0000 显示,底层存储 13800000000。
【实战要点】
- 经典应用场景:全名 = 姓 + 名、格式化手机号(显示带空格,存储无空格)、日期格式转换(YYYY-MM-DD ↔ 时间戳)。
- 常见坑:在 set 中修改了计算属性自身使用的依赖,必须确保逻辑正确,否则容易触发无限循环(set → 修改依赖 → get 重算 → 视图更新 → …)。实际上 Vue 对此有保护机制,但逻辑混乱会导致难以追踪的 bug。
- 最佳实践:setter 应当专注于「将新值分解并写回原始 data 属性」,不应在 setter 中发起 HTTP 请求或其他异步操作,那些副作用应放在 watch 中处理。
【本章小结】
| 函数简写 | ✅ | ❌(尝试赋值会报警告) | ❌ |
| 对象 get/set | ✅ | ✅ | ✅ |
记忆口诀:「只读用函数,读写用对象;setter 只管写回数据,不做别的事」。
【面试考点】
Q2:computed 的 setter 有什么用?什么情况下需要它?
A:computed 默认只有 getter(函数简写形式)。当需要将计算属性绑定到 v-model,或者希望通过修改「派生值」来反向更新原始数据时,就需要 setter。典型场景:fullName 计算属性由 firstName 和 lastName 合并而来,用户修改 fullName 输入框时,setter 将新值拆分后分别写回 firstName 和 lastName。需要注意:setter 不存储数据,它只是一个「将新值分解并写回底层 data」的中间层。
三、computed 底层原理:Watcher 与依赖收集
3.1 响应式系统三个核心角色
名词解释
- Observer:Vue 在初始化时遍历 data 对象,通过 Object.defineProperty 将每个属性转换为 getter/setter,注入响应式逻辑。
- Dep(Dependency):依赖收集器,每个响应式属性对应一个 Dep 实例,内部维护订阅该属性的 Watcher 列表(subs)。
- Watcher:观察者,分三种类型:渲染 Watcher(负责组件重渲染)、computed Watcher(负责计算属性重算)、user Watcher(即 watch 选项定义的侦听器)。
概念与底层原理
Vue2 的响应式系统基于 Object.defineProperty,其工作链路如下:
初始化阶段:
Observer 遍历 data
└→ 为每个属性定义 getter/setter
└→ 为每个属性创建一个 Dep 实例
访问属性(getter 触发):
Dep.target(当前活跃 Watcher)不为空
└→ dep.depend() → 将 Dep 加入 Watcher 的 deps
→ 将 Watcher 加入 Dep 的 subs
修改属性(setter 触发):
dep.notify()
└→ 遍历 subs,调用每个 Watcher 的 update()
【代码注释】这段伪代码用缩进层次描述了 Vue2 响应式系统的三个阶段:初始化(Observer 注入)、读取(依赖收集)、写入(派发更新)。Dep.target 是整个依赖收集机制的核心——它是一个全局变量,在 Watcher 执行其函数期间被设置为「当前 Watcher」,使得该函数内任何响应式属性的 getter 都能「知道」当前是谁在读取自己,从而将其加入订阅列表。这是一种「运行时拦截」而非「静态声明」的依赖收集方式,Vue 的「自动追踪」正是由此实现的。市面应用:理解这个机制后,就能解释为什么 Vue 的响应式属性必须在 data 中预先声明——Object.defineProperty 只能拦截已存在属性的访问,动态添加的属性没有 getter/setter,无法触发依赖收集。
computed Watcher 的特殊处理:
// 简化版 Vue 源码(packages/vue/src/v3/apiWatch.ts 的 Vue2 版本)
class Watcher {
constructor(vm, expOrFn, options) {
this.lazy = !!options.lazy // computed Watcher 的 lazy = true
this.dirty = this.lazy // 初始 dirty = true(需要计算)
// …
this.value = this.lazy ? undefined : this.get()
// ↑ lazy 时不立即执行 getter
}
get() {
Dep.target = this // 将自身设为当前活跃 Watcher
const value = this.getter() // 执行 getter,触发依赖属性的 dep.depend()
Dep.target = null // 收集完毕,清空
return value
}
update() {
if (this.lazy) {
this.dirty = true // computed Watcher 只标记脏位,不立即重算
} else {
this.run() // 渲染/user Watcher 立即执行
}
}
evaluate() {
if (this.dirty) {
this.value = this.get() // 真正执行 getter,更新缓存
this.dirty = false // 标记为干净(已缓存)
}
}
}
【代码注释】这段简化源码揭示了 computed Watcher 的两个关键设计:① lazy: true 使构造函数中不执行 this.get(),将初始计算延迟到第一次外部访问;② update() 方法在依赖变化时不立即重算,只将 dirty 置为 true,真正的重算在下次外部调用 evaluate() 时才发生。这两个特性组合实现了「惰性求值 + 按需缓存」的完整语义。对比渲染 Watcher:渲染 Watcher 没有 lazy,update() 直接调用 run() 重新渲染,因为视图必须及时更新。市面应用:理解这段源码是回答「Vue computed 原理」面试题的黄金素材,面试官听到 dirty 标志和 evaluate() 这两个细节时,会认可你确实读过源码而非背答案。

【代码注释】这张图展示了 computed 的依赖链:蓝色 data.str 的紫色 Dep 同时订阅了黄色 computed Watcher 和绿色渲染 Watcher。这里藏着一个常被忽略的细节——computed watcher 求值时执行 depend(),会让它订阅的所有 dep 反过来也收集上层的渲染 watcher(即 Dep.target)。因此 str 的 dep.subs 里同时有这两个 watcher。当 str 变化时,dep.notify() 先将 computed Watcher 的 dirty 置为 true,再通知渲染 Watcher 重新渲染;渲染时访问 reverseCom,发现 dirty = true,执行 evaluate() 重新计算并缓存。这条「computed 让上层 watcher 间接订阅底层数据」的链路,正是 Vue.js 技术揭秘 中 Watcher.prototype.depend 的作用——保证 computed 的依赖变化能最终驱动视图更新。
3.2 依赖收集全流程

【代码注释】这张图按 1~10 的编号还原了完整的依赖收集和更新过程:蓝色初始化(步骤 1-2)→ 黄色创建 lazy computed Watcher(步骤 3)→ 紫色依赖收集(步骤 4-6)→ 绿色缓存(步骤 7)→ 橙色派发更新(步骤 8)→ 黄色置脏(步骤 9)→ 绿色重算(步骤 10)。关键细节:Dep.target 是一个全局变量,在 getter 执行期间被 pushTarget 指向当前正在求值的 Watcher,这是实现「自动依赖收集」的核心——开发者不需要显式声明依赖,Vue 通过「执行 getter 期间拦截属性访问 + 全局指针」自动建立订阅关系。底层延伸:正因为 Object.defineProperty 只能拦截初始化时已存在的属性,所以事后用 this.obj.newKey = x 新增的属性没有 getter,无法被收集成依赖——这也是 computed 追踪不到动态新增属性、必须用 Vue.set 的根因。
3.3 computed Watcher 与 lazy 标志
为什么 computed 需要 lazy 而渲染 Watcher 不需要?
渲染 Watcher 的职责是「组件有变化就立即重渲染」,需要及时响应。而 computed 的值可能根本没有人访问(比如某个分支的 computed 在当前条件下不会被渲染),如果也立即重算,就是纯粹的浪费。lazy: true 确保「按需计算」,只有真正被访问时才执行 getter。
【实战要点】
- 经典应用场景:大型列表的过滤/排序(如商品列表按价格排序、评分筛选),用 computed 只在依赖变化后才重新处理,比在 mounted 中手动操作高效得多。
- 常见坑:computed 不能追踪通过下标修改数组(this.arr[0] = 1)或直接添加新属性(this.obj.newProp = 1)——这是 Object.defineProperty 的固有限制。应改用 Vue.set() 或数组的变异方法(push/splice 等)。
- 性能特征:每个 computed Watcher 本身只占用少量内存(Dep.subs 数组中的一个引用);真正的性能消耗在于 getter 本身的计算复杂度——应避免在 computed 中进行 O(n²) 的嵌套循环。
【本章小结】
Vue2 响应式系统的核心公式:Object.defineProperty + Dep + Watcher。
| Observer | 将 data 转为响应式(注入 getter/setter) | Vue 实例初始化时 |
| Dep | 管理订阅列表,通知变化 | 每个响应式属性创建时 |
| computed Watcher | lazy 求值,缓存结果 | Vue 处理 computed 选项时 |
| 渲染 Watcher | 组件重渲染 | 组件首次挂载时 |
记忆口诀:「defineProperty 装 getter,getter 触发 depend 收集,setter 触发 notify 派发,computed 的 Watcher 多个 lazy」。
【面试考点】
Q3:Vue2 的依赖收集是如何实现的?computed 在其中扮演什么角色?
A:Vue2 使用 Object.defineProperty 为每个 data 属性注入响应式 getter/setter。每个属性对应一个 Dep 实例,维护订阅该属性的 Watcher 列表。当某个 Watcher(渲染 Watcher 或 computed Watcher)执行其函数时,通过将自身赋值给全局变量 Dep.target,使得函数执行过程中任何被访问属性的 getter 都能调用 dep.depend(),将这个 Watcher 自动加入订阅列表——这就是「依赖收集」。computed Watcher 特殊在于 lazy: true:不立即执行 getter,而是等第一次被访问时才执行,并用 dirty 标志控制缓存的有效性,避免无谓的重复计算。
四、computed vs methods vs watch 深度对比
4.1 三者的本质差异
名词解释
- methods:Vue 实例的方法,本质是普通函数,在模板中通过 fn() 调用,每次调用都执行。
- computed:基于响应式依赖的缓存属性,在模板中像属性一样使用(无括号),有缓存。
- watch:侦听器,监听某个数据源的变化,在变化时执行副作用(callback),可以是异步的。

【代码注释】这张决策树是「选哪个」的快速答案。绿色的 computed 是最常用的路径:从已有数据派生值且不需要参数。黄色的 watch 专门处理异步副作用(网络请求、定时器等)。蓝色的 methods 处理需要参数的计算或事件处理;灰色的 data 直接存储用于「既不派生也无副作用」的纯状态。沿着两个黄色判断节点(是否派生值、是否异步副作用)走下来,每一类需求都有唯一落点。底层依据:官方文档明确建议「当你有一些数据需要随着其他数据变动而变动时……通常更好的做法是使用计算属性而不是命令式的 watch 回调」(计算属性 vs 侦听器)——这正是决策树把 computed 放在主干路径的原因。市面应用:团队代码评审常以这张图为 checklist,发现「watch 里做了 computed 该做的派生」就要求重构。
4.2 watch 侦听器详解
watch 用于在数据变化时执行副作用,弥补了 computed 不能处理异步逻辑的不足。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>watch 侦听器实战</title>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<style>
body { font-family: sans-serif; padding: 20px; max-width: 600px; }
input { padding: 8px; border: 1px solid #cbd5e1; border-radius: 6px; width: 260px; }
.result { margin: 12px 0; padding: 12px; background: #f0fdf4; border-radius: 6px; color: #166534; }
.loading { color: #92400e; font-style: italic; }
</style>
</head>
<body>
<div id="app">
<input v-model="keyword" placeholder="输入搜索关键词(模拟防抖搜索)">
<p class="loading" v-if="isLoading">搜索中……</p>
<div class="result" v-if="results.length">
<p v-for="r in results" :key="r">{{ r }}</p>
</div>
<p style="color:#64748b" v-if="!results.length && !isLoading && keyword">
未找到相关结果
</p>
</div>
<script>
// 模拟异步搜索接口
function mockSearch(keyword) {
const data = ['Vue 计算属性', 'Vue 侦听器', 'Vue 响应式原理', 'Vue 组件通信', 'Vue Router', 'Vuex 状态管理'];
return new Promise(resolve => {
setTimeout(() => {
resolve(data.filter(item => item.includes(keyword)));
}, 600);
});
}
new Vue({
el: '#app',
data: {
keyword: '',
results: [],
isLoading: false,
timer: null
},
watch: {
// 侦听 keyword 变化,执行带防抖的异步搜索
keyword(newVal, oldVal) {
// 防抖:清除上一次的定时器
if (this.timer) clearTimeout(this.timer);
if (!newVal.trim()) {
this.results = [];
return;
}
this.isLoading = true;
this.timer = setTimeout(async () => {
// watch 中可以使用 async/await,这是 computed 做不到的
this.results = await mockSearch(newVal);
this.isLoading = false;
}, 500);
}
}
});
</script>
</body>
</html>
【代码注释】这个示例展示了 watch 的核心价值:处理异步副作用(setTimeout + async/await)。watch 的回调接收 (newVal, oldVal) 两个参数,可以对比新旧值。防抖模式(清除旧定时器再设新定时器)是 watch 的经典配合用法。市面应用:搜索框的联想词请求(防抖)、表单字段校验(延迟 300ms 后触发)、路由参数变化时重新拉取数据(watch: { '$route.params.id': handler })。
watch 的高级选项
watch: {
// 对象写法,支持 immediate 和 deep
userInfo: {
handler(newVal, oldVal) {
console.log('userInfo 变化了', newVal);
},
deep: true, // 深度监听:监听对象内部属性的变化
immediate: true // 立即执行:组件创建时立即调用一次 handler
}
}
【代码注释】watch 的对象写法支持三个选项:handler(变化时执行的回调)、deep(是否深度监听对象/数组内部变化)、immediate(是否在组件挂载时立即执行一次)。deep: true 的代价是性能较高:Vue 会递归遍历被监听对象的所有属性并订阅它们,对于大型嵌套对象(如深层次的树形数据)应谨慎使用。immediate: true 避免了「mounted 里拉一次数据,watch 里拉一次数据」的代码重复,是处理「组件初始化 + 参数变化都需要刷新」场景的最简写法。市面应用:Vue Router 中监听 $route.params 的经典写法就是 watch: { '$route': { handler: fetchData, immediate: true, deep: true } }。
- deep: true:默认情况下 watch 不深度监听对象内部变化(因为 Object 引用未变),设置 deep: true 后会递归遍历所有属性。代价是性能较高,谨慎用于大对象。
- immediate: true:通常 watch 只在数据变化后才触发。设置 immediate: true 后,组件挂载时立即执行一次,适合「初始加载 + 后续更新都走同一套逻辑」的场景。
deep 与 immediate 的底层原理
这两个选项不是简单的"开关",理解其源码实现能帮你判断性能开销与触发时机:
-
deep 的本质是「递归触发 getter 完成全量订阅」。watch 默认创建的 user Watcher 在求值时只读取了 userInfo 这个引用本身,因此只订阅了 userInfo 属性的 dep——你改 userInfo.name 不会让引用变化,回调自然不触发。设置 deep: true 后,Watcher 求值结束会额外调用一个 traverse(value) 函数,递归遍历对象/数组的每一个嵌套属性并逐个读取它们。由于此刻 Dep.target 仍指向这个 user Watcher,每次读取都触发对应属性的 getter → dep.depend(),于是该 Watcher 一次性订阅了所有层级的 dep。这就是 deep 能侦测深层变化的真相,也是它「大对象慎用」的原因——traverse 是 O(n) 的全量遍历,且每个嵌套属性都新增一条订阅关系,内存与遍历开销都随对象规模线性增长。traverse 内部还用一个 seenObjects 集合记录已访问对象,避免循环引用导致死循环。
-
immediate 的本质是「初始化时主动调用一次回调」。watch 选项在 $watch 内部创建 user Watcher 后,会检查 options.immediate,若为真则立刻以当前值(newVal)执行一次 handler(此时 oldVal 为 undefined)。它不改变响应式机制,只是省去了你手动「在 created 里先调一次、再在 watch 里重复同样逻辑」的样板代码。注意 immediate 回调在 watcher 创建的同步阶段就执行,早于首次渲染。
官方文档对 deep/immediate 着墨不多,但 侦听器 watch API 章节给出了选项定义,源码细节可参考 Vue.js 技术揭秘 对 traverse 与 user watcher 的解析。
4.3 选择决策树
| 从多个 data 派生一个展示值 | computed | 有缓存,声明式,模板简洁 |
| 需要传参的计算 | methods | computed 不支持传参 |
| 用户输入实时触发 HTTP 请求 | watch + 防抖 | computed 不支持异步 |
| 监听 prop/route 变化重新拉取数据 | watch | 需要在数据变化时执行副作用 |
| 购物车总价 | computed | 从商品数组派生一个数字,有缓存 |
| 首次加载 + 路由参数变化都拉数据 | watch + immediate | 初始执行 + 后续监听一套代码 |
【实战要点】
- 经典应用场景:watch 用于数据同步到外部系统——修改 theme 数据后 watch 触发 localStorage.setItem('theme', newVal);computed 用于所有「基于现有数据展示新形态」的场景。
- 常见坑:用 watch 来做 computed 能做的事(比如用 watch 监听 firstName 和 lastName 分别更新 fullName),会导致 watch 回调比 computed getter 晚一个 tick 触发,出现「闪烁」(先显示旧 fullName,再更新)。能用 computed 的,不要用 watch。
- 最佳实践:官方文档有一句经典表述——「当你有一些数据需要随着其他数据变动而变动时,你很容易滥用 watch。然而,通常更好的做法是使用计算属性而不是命令式的 watch 回调。」(官方链接)
【本章小结】
computed:声明式,有缓存,无副作用,无异步 → 数据派生
methods:命令式,无缓存,可传参,可副作用 → 事件处理/需要参数的计算
watch:命令式,无缓存,可异步,可拿新旧值 → 数据变化时的副作用
【代码注释】这三行总结是「选用哪种方式」的速查卡。关键判断维度:① 是否需要派生一个值(→ computed);② 是否需要传参(→ methods);③ 是否有异步操作或需要访问旧值(→ watch)。Vue 设计三种选项并非「功能重叠」,而是为不同职责设计的专用工具,组合使用才能写出结构清晰的 Vue 代码。市面应用:Vue 官方风格指南(链接)明确建议「有缓存需求时使用 computed」,这是团队代码评审时的重要检查点。
记忆口诀:「派生用 computed,副作用用 watch,传参用 methods」。
【面试考点】
Q4:computed 和 watch 的区别?什么时候用 computed,什么时候用 watch?
A:这是 Vue 面试最高频的问题,从三个维度回答:
缓存:computed 有缓存(dirty 标志),依赖不变时多次访问只执行一次 getter;watch 的回调每次依赖变化都会执行,无缓存概念。
同步/异步:computed getter 必须是同步的,返回值会被立即使用;watch 的回调可以是异步的(setTimeout、Promise、async/await 均可)。
使用场景:computed 适合「从现有数据派生一个新值」(如购物车总价、全名拼接、列表过滤),在模板中直接使用;watch 适合「数据变化时执行副作用」(如发 HTTP 请求、写 localStorage、操作 DOM),不关心返回值。
追问「为什么不用 watch 来替代所有 computed?」:watch 回调触发是异步(nextTick 后)的,如果用 watch 监听多个字段来更新某个展示值,会出现一帧的延迟(旧值 → 新值的过渡闪烁)。computed 是同步的,依赖变化时当次渲染就能拿到最新值。
五、购物车综合案例:computed 的实战价值
5.1 需求分析
名词解释
- 链式依赖(chained computed):一个 computed 的 getter 中访问了另一个 computed,从而形成「A 依赖 B、B 依赖底层 data」的多级派生关系。
- 聚合计算:用 filter/reduce 等数组方法把列表「卷」成单个标量值(如总价、件数),是购物车类需求的核心运算。
- 数组变异方法:Vue2 重写了 push/pop/shift/unshift/splice/sort/reverse 七个方法,调用它们会触发响应式更新;而下标赋值(arr[i] = x)和 length 修改不会。
概念与底层原理
购物车把 computed 的三大特性同时压到一个真实业务里:① 多依赖自动追踪——originalTotal 的 getter 内 filter 遍历了 goods 数组中每个元素的 checked/price/count,Vue 在求值期间逐个触发它们的 getter 完成 dep.depend(),因此任意一个商品的勾选或数量变化都会让 originalTotal 置脏;② 链式缓存——finalTotal 依赖 discountInfo,discountInfo 又依赖 originalTotal,每一层都持有独立的 dirty 标志,底层 data 不变时整条链都命中缓存,避免了「改一个数全链路重算」的浪费;③ 数组响应式的边界——删除商品必须用 splice 而非 delete,因为只有被 Vue 劫持的变异方法才会调用 ob.dep.notify() 通知依赖该数组的 Watcher。
这些都是「从现有数据派生值」的典型需求,用 computed 代码清晰、性能最优。
官方文档参考:Vue2 列表渲染 – 数组更新检测 给出了被劫持的变异方法清单与下标赋值失效的根因。
购物车是 computed 最经典的应用场景,因为:
- 总价 = Σ(单价 × 数量),依赖多个商品的 price 和 count
- 折扣价 = 总价 × 折扣率,依赖总价
- 是否满足满减条件,依赖总价阈值
5.2 完整购物车实现
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>购物车 – computed 综合应用</title>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; padding: 20px; background: #f8fafc; }
.cart { max-width: 700px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 16px rgba(0,0,0,.08); }
.cart-header { padding: 16px 24px; background: #1e293b; color: white; font-size: 18px; font-weight: 600; }
.cart-item { display: flex; align-items: center; padding: 16px 24px; border-bottom: 1px solid #f1f5f9; gap: 12px; }
.item-check input { width: 16px; height: 16px; cursor: pointer; }
.item-name { flex: 1; font-weight: 500; color: #1e293b; }
.item-price { color: #ef4444; width: 80px; text-align: right; }
.item-qty { display: flex; align-items: center; gap: 6px; }
.qty-btn { width: 28px; height: 28px; border: 1px solid #e2e8f0; background: white; border-radius: 6px; cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center; }
.qty-btn:hover { background: #f1f5f9; }
.qty-btn:disabled { opacity: .4; cursor: not-allowed; }
.qty-num { width: 32px; text-align: center; font-weight: 600; }
.item-subtotal { color: #ef4444; width: 90px; text-align: right; font-weight: 600; }
.item-del { color: #94a3b8; cursor: pointer; padding: 4px 8px; border-radius: 4px; }
.item-del:hover { color: #ef4444; background: #fee2e2; }
.cart-footer { padding: 20px 24px; }
.discount-tip { padding: 10px 14px; border-radius: 8px; margin-bottom: 12px; font-size: 14px; }
.discount-tip.active { background: #dcfce7; color: #166534; }
.discount-tip.inactive { background: #fef3c7; color: #92400e; }
.price-row { display: flex; justify-content: space-between; padding: 6px 0; color: #64748b; }
.price-row.total { font-size: 18px; font-weight: 700; color: #1e293b; border-top: 2px solid #f1f5f9; padding-top: 12px; margin-top: 4px; }
.price-val { color: #ef4444; }
.checkout-btn { width: 100%; padding: 14px; background: #ef4444; color: white; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; margin-top: 16px; }
.checkout-btn:hover { background: #dc2626; }
.checkout-btn:disabled { background: #cbd5e1; cursor: not-allowed; }
.select-all { padding: 12px 24px; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; gap: 8px; color: #64748b; font-size: 14px; }
</style>
</head>
<body>
<div id="cart">
<div class="cart">
<div class="cart-header">🛒 我的购物车</div>
<!– 全选 –>
<div class="select-all">
<input type="checkbox" v-model="allChecked"> 全选
<span style="margin-left:auto">已选 {{ checkedCount }} 件</span>
</div>
<!– 商品列表 –>
<div class="cart-item" v-for="item in goods" :key="item.id">
<div class="item-check">
<input type="checkbox" v-model="item.checked">
</div>
<div class="item-name">{{ item.name }}</div>
<div class="item-price">¥{{ item.price.toFixed(2) }}</div>
<div class="item-qty">
<button class="qty-btn" @click="item.count–" :disabled="item.count <= 1">−</button>
<span class="qty-num">{{ item.count }}</span>
<button class="qty-btn" @click="item.count++" :disabled="item.count >= 99">+</button>
</div>
<div class="item-subtotal">¥{{ (item.price * item.count).toFixed(2) }}</div>
<span class="item-del" @click="removeItem(item.id)">删除</span>
</div>
<!– 结算区 –>
<div class="cart-footer">
<!– 满减提示 –>
<div class="discount-tip" :class="discountInfo.class">
{{ discountInfo.tip }}
</div>
<!– 价格明细 –>
<div class="price-row">
<span>商品合计</span>
<span class="price-val">¥{{ originalTotal }}</span>
</div>
<div class="price-row" v-if="discountInfo.save > 0">
<span>优惠折扣({{ discountInfo.rate * 10 }}折)</span>
<span style="color:#22c55e">-¥{{ discountInfo.save }}</span>
</div>
<div class="price-row total">
<span>实付金额</span>
<span class="price-val">¥{{ finalTotal }}</span>
</div>
<button class="checkout-btn" :disabled="checkedCount === 0">
结算({{ checkedCount }} 件)
</button>
</div>
</div>
</div>
<script>
new Vue({
el: '#cart',
data: {
goods: [
{ id: 1, name: 'Vue.js 设计与实现(书籍)', price: 89.00, count: 1, checked: true },
{ id: 2, name: '机械键盘(青轴)', price: 349.00, count: 1, checked: true },
{ id: 3, name: 'USB-C 数据线(1m)', price: 29.90, count: 3, checked: false },
{ id: 4, name: '显示器支架', price: 188.00, count: 1, checked: true }
]
},
computed: {
// 已勾选的商品数量(件数之和)
checkedCount() {
return this.goods
.filter(item => item.checked)
.reduce((sum, item) => sum + item.count, 0);
},
// 已勾选商品的原价合计
originalTotal() {
const total = this.goods
.filter(item => item.checked)
.reduce((sum, item) => sum + item.price * item.count, 0);
return total.toFixed(2);
},
// 折扣与满减信息(依赖 originalTotal)
discountInfo() {
const total = parseFloat(this.originalTotal);
if (total >= 500) {
// 满 500 享 8.5 折
const save = (total * 0.15).toFixed(2);
return { rate: 8.5, save, class: 'active', tip: `🎉 已享 8.5 折优惠,节省 ¥${save}` };
} else if (total >= 200) {
// 满 200 享 9.5 折
const save = (total * 0.05).toFixed(2);
return { rate: 9.5, save, class: 'active', tip: `🎉 已享 9.5 折优惠,节省 ¥${save}` };
} else {
const diff = (200 – total).toFixed(2);
return { rate: 1, save: 0, class: 'inactive', tip: `再购 ¥${diff} 可享 9.5 折优惠` };
}
},
// 实付金额(依赖 discountInfo)
finalTotal() {
const total = parseFloat(this.originalTotal);
const result = total * this.discountInfo.rate / 10;
return result.toFixed(2);
},
// 全选状态(可读写)
allChecked: {
get() {
return this.goods.length > 0 && this.goods.every(item => item.checked);
},
set(val) {
this.goods.forEach(item => item.checked = val);
}
}
},
methods: {
removeItem(id) {
const idx = this.goods.findIndex(item => item.id === id);
if (idx !== –1) this.goods.splice(idx, 1);
}
}
});
</script>
</body>
</html>
【代码注释】这个购物车综合了 computed 的多种用法:
splice 用于删除商品而非 delete,是因为 splice 是 Vue2 劫持的数组变异方法,能触发响应式更新;而 delete arr[idx] 和下标赋值 arr[idx] = x 均无法触发响应式。市面应用:淘宝、京东、拼多多等电商平台的购物车页面均采用类似的 computed 链式计算模式。
【实战要点】
- 经典应用场景:购物车总价、折扣计算、优惠券适用性判断、分期付款金额——所有「从商品列表派生数值」的场景。
- 常见坑:originalTotal 返回的是字符串(toFixed() 的返回类型),discountInfo 中需要 parseFloat(this.originalTotal) 转回数字再计算。切忌混淆字符串数字与数值类型的计算。
- 性能最佳实践:computed 链(A 依赖 B,B 依赖 C)是高效的——每层都有自己的缓存,只有最上游数据变化才触发重算。不要将所有逻辑堆在一个巨型 computed 中,拆成多层有助于缓存命中和调试。
【本章小结】
购物车案例展示了 computed 四种核心能力:
| 聚合计算 | checkedCount / originalTotal | filter + reduce | 列表卷成标量,自动随勾选/数量重算 |
| 条件派生 | discountInfo 满减分支 | if/else 返回对象 | 折扣规则集中一处,模板只读结果 |
| 链式依赖 | finalTotal 依赖 discountInfo 依赖 originalTotal | computed 调 computed | 分层缓存,改一处只重算受影响层 |
| 双向绑定 | allChecked 全选 | get/set + v-model | get 反映状态、set 批量写回,无需 watch |
记忆口诀:「列表用 reduce 卷成值,规则进 computed 分层算,全选用 get/set 双向走,删除只认 splice 不认 delete」。
【面试考点】
Q5:如何用 computed 实现购物车的全选功能?
A:使用 computed 的完整 get/set 写法。get() 返回 goods.every(item => item.checked) 来判断是否全部选中;set(val) 遍历 goods,将每个商品的 checked 设为 val。这样 v-model="allChecked" 就能实现:点击全选 checkbox 时触发 set(批量修改),某个商品取消勾选时 get 重算(取消全选状态)。相比用 watch 监听每个商品的 checked 属性,这种方式更简洁且无延迟。
六、v-cloak 与渲染闪烁问题
6.1 问题描述
名词解释
- FOUC(Flash of Unstyled Content):页面加载时短暂显示未渲染内容的闪烁现象,在 Vue 中表现为 {{str}} 等插值表达式的花括号短暂显示。
- v-cloak:Vue 内置指令,在 Vue 实例挂载完成之前保留在元素上,挂载完成后自动移除。配合 CSS 使用,可以完全隐藏未渲染内容。
概念与底层原理
当 Vue 脚本加载或执行较慢(如 CDN 延迟、或 DOM 中有大量列表等待渲染),浏览器会先将原始 HTML 显示出来——用户会看到 {{str}}、{{item.name}} 等花括号原文,然后 Vue 挂载完成后才替换为真实内容。这个闪烁在网速慢或 setTimeout 延迟挂载时尤为明显。
解决方案:
v-cloak 是 Vue 编译期识别的内置指令,它不参与运行时逻辑,唯一作用是「在模板编译挂载结束时被框架移除」。其有效性建立在一个时序假设上:浏览器解析 <head> 中的内联 CSS 早于异步加载并执行 Vue 脚本,因此 [v-cloak]{display:none} 会先于 Vue 挂载生效;待 $mount 完成、v-cloak attribute 被移除后,属性选择器不再匹配,元素自然显现。这也解释了为什么样式必须内联在 <head> 而非外链——外链 CSS 的异步加载可能晚于花括号被浏览器渲染。
官方文档参考:Vue2 API – v-cloak 明确定义其语义为「这个指令保持在元素上直到关联实例结束编译」。

【代码注释】v-cloak 的核心思路是「先藏后显」:灰色「页面加载」后,黄色阶段里 [v-cloak]{display:none} 的 CSS 立即生效把元素隐藏(此时用户看不到花括号原文);蓝色阶段 Vue 脚本执行、实例初始化;绿色阶段 Vue 挂载完成、把数据编译进模板,并自动移除 v-cloak 属性,CSS 选择器随之失效、元素恢复可见——此刻看到的已是渲染后的真实数据。底层依据:v-cloak 是 Vue 编译时识别的内置指令,挂载结束($mount 完成)时框架会移除该 attribute;它的有效性完全依赖「CSS 解析早于 Vue 脚本执行」的时序差,所以样式必须内联在 <head>。市面应用:用 CDN 裸引 Vue 的传统多页应用(MPA)标配此方案;Vite/Vue CLI 的 SPA 由打包器控制加载顺序,通常不需要。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>v-cloak 防闪烁</title>
<style>
/* 关键:必须写在 <head> 中,比 Vue 脚本更早加载 */
[v-cloak] {
display: none;
}
body { font-family: sans-serif; padding: 20px; }
.list-item { padding: 8px 12px; margin: 4px 0; background: #f0fdf4; border-radius: 6px; color: #166534; }
</style>
<!– Vue 脚本放在 head 中(模拟脚本延迟加载场景) –>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</head>
<body>
<!– v-cloak 加在最外层容器上,覆盖所有插值表达式 –>
<div id="app" v-cloak>
<h3>商品列表</h3>
<div class="list-item" v-for="(item, index) in list" :key="index">
{{ item }}
</div>
</div>
<script>
// 模拟 Vue 延迟 2 秒挂载(相当于 CDN 延迟加载场景)
setTimeout(() => {
new Vue({
el: '#app',
data: {
list: ['苹果 ¥5.00/斤', '香蕉 ¥3.50/斤', '葡萄 ¥12.00/斤', '西瓜 ¥2.00/斤']
}
});
// Vue 挂载完成后,v-cloak 自动移除,列表以正确数据显示
}, 2000);
</script>
</body>
</html>
【代码注释】CSS 样式必须写在 <head> 中,且先于 v-cloak 元素加载,否则 CSS 来不及生效。display: none 是最常用的隐藏方式;在某些布局场景下也可使用 visibility: hidden 来保留占位空间。市面应用:所有需要 CDN 引入 Vue 的传统多页应用(非脚手架项目)都需要考虑这个问题,是「裸 Vue 项目」的标配配置。使用 Vue CLI 或 Vite 的 SPA 项目由于有 Webpack/Rollup 控制加载顺序,通常不需要手动处理。
【实战要点】
- 经典应用场景:传统 MPA(多页应用)中使用 CDN 引入 Vue,特别是在网络较慢的 C 端页面。
- 常见坑:[v-cloak] 的 CSS 写在 <body> 中或通过外部 CSS 文件异步加载,都可能导致它「来不及」在 Vue 加载前生效,flash 依然出现。正确做法是内联在 <head> 中的 <style> 标签里。
- 替代方案:将 Vue 初始化脚本提前,或使用 v-once 对静态内容跳过响应式处理,也能部分缓解问题。
【本章小结】
| 触发时机 | Vue 实例挂载前存在,挂载完成后自动移除 |
| 配套 CSS | [v-cloak] { display: none } 写在 <head> 内联样式中 |
| 适用范围 | 整个挂载容器或任何子元素 |
| 原理 | 利用 CSS 属性选择器 + 属性移除的时序差实现「先隐后显」 |
记忆口诀:「v-cloak 加容器,样式塞进 head,CSS 先藏挂载后显,外链异步会失灵」。
【面试考点】
Q6:v-cloak 是什么?如何使用?
A:v-cloak 是 Vue 的内置指令,用于解决页面初始化时插值表达式(如 {{msg}})短暂显示花括号原文的「闪烁」问题。使用方法:① 在模板容器上添加 v-cloak 属性;② 在 <head> 内联 <style> 中写 [v-cloak] { display: none }。原理:DOM 解析后 CSS 立即生效,元素被隐藏;Vue 脚本执行完毕挂载后,自动移除 v-cloak 属性,CSS 规则失效,元素恢复显示——此时模板已编译完毕,用户只会看到真实数据。
七、computed 的进阶模式
7.1 多依赖聚合
名词解释
- 多依赖:一个 computed 属性的 getter 中访问了多个响应式属性,任意一个变化都会触发重算。
- 依赖图:computed 之间可以互相依赖,形成一个 DAG(有向无环图)的依赖图。
概念与底层原理
「多依赖」与「链式依赖」本质上是同一套依赖收集机制在不同形态下的体现。Vue 的依赖追踪是运行时的「读到即订阅」:getter 执行期间,Dep.target 指向当前 Watcher,凡是被读取过的响应式属性(无论是 data 还是另一个 computed)都会调用 dep.depend() 把这个 Watcher 收进自己的 subs。所以 getter 里访问几个属性,就自动订阅几个 dep——开发者无需任何显式声明。当一个 computed 访问另一个 computed 时,被访问者会先 evaluate() 求值,并在求值结束后让自己的依赖反向收集上层 Watcher(即 Watcher.prototype.depend),从而把「底层 data → computedB → computedA → 渲染 Watcher」整条更新链路打通。每一层都有独立的 dirty 标志,不在变化链上的 computed 不会被置脏,缓存完全有效——这就是链式 computed「精确重算」的根源。
官方文档参考:Vue2 计算属性缓存 vs 方法 说明 computed 仅在相关响应式依赖改变时才重新求值,链式依赖同样遵循此规则。
computed 不限制只依赖一个属性,也不限制 computed 只能依赖 data,它可以依赖另一个 computed:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>computed 多依赖与链式依赖</title>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<style>
body { font-family: sans-serif; padding: 20px; max-width: 500px; }
.row { display: flex; align-items: center; gap: 12px; margin: 10px 0; }
label { width: 80px; color: #64748b; font-size: 14px; }
input[type=range] { flex: 1; }
.result { padding: 12px; background: #f0fdf4; border-radius: 8px; margin-top: 16px; }
.result-row { display: flex; justify-content: space-between; padding: 4px 0; }
.val { font-weight: 600; color: #166534; }
</style>
</head>
<body>
<div id="app">
<h3>BMI 计算器</h3>
<div class="row">
<label>身高</label>
<input type="range" v-model.number="height" min="140" max="210" step="1">
<span>{{ height }} cm</span>
</div>
<div class="row">
<label>体重</label>
<input type="range" v-model.number="weight" min="40" max="150" step="0.5">
<span>{{ weight }} kg</span>
</div>
<div class="result">
<div class="result-row">
<span>BMI 值</span>
<span class="val">{{ bmi }}</span>
</div>
<div class="result-row">
<span>状态</span>
<span class="val">{{ bmiStatus }}</span>
</div>
<div class="result-row">
<span>建议体重范围</span>
<span class="val">{{ idealWeightRange }}</span>
</div>
</div>
</div>
<script>
new Vue({
el: '#app',
data: {
height: 170, // 单位:cm
weight: 65 // 单位:kg
},
computed: {
// 第一层:从 height 和 weight 计算 BMI(多依赖)
bmi() {
const h = this.height / 100; // 转为米
return (this.weight / (h * h)).toFixed(1);
},
// 第二层:从 bmi(computed)派生状态描述(computed 依赖 computed)
bmiStatus() {
const val = parseFloat(this.bmi);
if (val < 18.5) return '⚠️ 偏瘦';
if (val < 24) return '✅ 正常';
if (val < 28) return '⚠️ 超重';
return '❌ 肥胖';
},
// 第三层:从 height 计算理想体重范围(只依赖 height)
idealWeightRange() {
const h = this.height / 100;
const low = (18.5 * h * h).toFixed(1);
const high = (24 * h * h).toFixed(1);
return `${low} kg ~ ${high} kg`;
}
}
});
</script>
</body>
</html>
【代码注释】bmi 依赖 height 和 weight 两个 data 属性(多依赖);bmiStatus 依赖 bmi(computed 依赖 computed);idealWeightRange 只依赖 height。当 weight 变化时,bmi 和 bmiStatus 的 dirty 被置 true,idealWeightRange 不受影响(缓存命中)。这就是多依赖 + 链式依赖的精确缓存特性。市面应用:健康类 App 的体征指数计算、金融计算器(利率→月供→总利息)、图形工具(宽高→面积→对角线长度)。
7.2 computed 的常见误区
误区一:在 computed 中直接修改 data
// ❌ 错误:在 getter 中产生副作用
computed: {
badComputed() {
this.count++; // 修改 data,导致无限递归触发!
return this.count;
}
}
【代码注释】这段代码演示了 computed getter 中最危险的误用:在 getter 里修改响应式数据。this.count++ 会触发 count 的 setter → setter 调用 dep.notify() → 通知 badComputed 的 Watcher 重算 → 再次执行 getter → 再次 this.count++ → 无限循环,最终导致「Maximum call stack size exceeded」。Vue 在开发模式下会打印警告,但不会阻止代码运行。市面应用:代码评审中若发现 computed 里有赋值操作(this.xxx = …),应立即标记为 bug;唯一的例外是 set() 方法(对象写法的 setter),那才是合法的写入位置。
computed getter 应当是纯函数——相同输入始终产生相同输出,不产生可观察的副作用。
误区二:将异步操作放在 computed 中
// ❌ 错误:异步操作在 computed 中无效
computed: {
async asyncData() {
const result = await fetch('/api/data');
return result.json(); // 这个返回值不会被 Vue 用到!
// getter 会立即返回一个 Promise 对象,而不是数据
}
}
【代码注释】async 函数无论内部如何 await,本身总是同步地返回一个 Promise 对象。Vue 的 computed 系统在调用 getter 时会同步接收返回值——它收到的是 Promise,而不是异步完成后的真实数据。模板会直接渲染这个 Promise 对象([object Promise]),完全不符合预期。异步数据获取应通过 watch 监听触发条件,在回调里用 async/await 发请求,将结果存入 data,再由 computed 进行同步的数据派生。市面应用:这是 Vue 新手最常踩的坑之一,在 Stack Overflow 上有大量相关问题(搜索「vue computed async」);Vue 3 引入的 vue-concurrency / @vueuse/core 的 useAsyncState 提供了「在 setup 中优雅处理异步状态」的解决方案。
正确做法:在 watch 中监听依赖,在 watch 的 async 回调中更新 data。
误区三:在 computed 中打日志来"调试缓存"
// ⚠️ 陷阱:console.log 本身是副作用,但不影响 computed 正确性
// 只是要注意:如果你在 getter 里 console.log,
// 打印次数少于 "模板引用次数" 正是缓存生效的证明,不是 bug!
computed: {
value() {
console.log('getter 被调用'); // 多次引用只打印一次,正确行为
return this.str.toUpperCase();
}
}
【代码注释】这段代码是「理解 computed 缓存」的调试模板。在实际项目中可以临时加入 console.log 来验证缓存是否按预期工作:如果模板中 {{ value }} 出现 5 次,但控制台只打印 1 条「getter 被调用」,说明缓存正常工作。如果每次有任何 data 变化都打印,检查 getter 是否访问了不必要的响应式属性(Vue 的依赖追踪是「凡是读过就订阅」,访问无关属性会引入不必要的依赖)。验证完毕后应移除 console.log,避免生产环境的性能损耗和信息泄露。市面应用:Vue DevTools 提供了计算属性的可视化面板,可以直接看到每个 computed 的当前值和依赖,是比 console.log 更高效的调试方式。
【实战要点】
- 经典应用场景:表单数据完整性验证(多个字段同时满足条件才允许提交),isFormValid computed 依赖所有字段的 data,任一字段变化都自动重算。
- 常见坑:computed 不支持 $watch 对象属性的动态变化(同 data 限制)。如果 computed 依赖的是对象,对象内部属性变化不会触发重算(需要 Vue.set 或解构赋值让引用变化)。
- 最佳实践:命名 computed 属性时使用名词(fullName、totalPrice、isValid),这提醒阅读者「这是一个值,不是一个动作」,区别于 methods 的动词命名(calculateTotal、validateForm)。
【本章小结】
computed 进阶要点:
- 可以依赖任意数量的响应式属性(自动追踪,无需声明)
- 可以依赖另一个 computed,形成链式推导(每层有独立缓存)
- getter 必须是纯函数,不能有异步操作和副作用
| getter 中改 data | this.count++ | 无限递归、爆栈 | getter 只读不写,写入放 set() |
| getter 中写异步 | async getter() / await fetch | 模板渲染出 [object Promise] | 用 watch 发请求,结果存 data |
| 依赖对象内部属性 | 改 obj.key 不重算 | 视图不更新 | Vue.set 或让引用变化 |
| 误判缓存为 bug | 打印次数少于引用次数 | 误以为没生效 | 这正是缓存命中的证明 |
记忆口诀:「getter 纯函数只读值,异步交给 watch 跑,对象改内层用 Vue.set,打印变少是缓存好」。
【面试考点】
Q7:computed 可以依赖另一个 computed 吗?缓存还会生效吗?
A:可以。Vue 对 computed 之间的依赖同样通过 Dep/Watcher 机制自动追踪。当 computedA 的 getter 中访问了 computedB,Vue 会建立依赖关系:computedB 的 Dep 中会订阅 computedA 的 Watcher。当最底层的 data 变化时,依赖链从底向上传播——computedB 的 dirty 先置为 true,然后 computedA 的 dirty 也置为 true。每一层都有自己的 dirty 标志和缓存,不受影响的层(不在依赖链上的 computed)不会重算,缓存完全有效。
总结
知识点回顾(思维导图)

【代码注释】这张全景图以「Vue2 计算属性」为根,从「本质」「原理」「对比」「实战」「相关」五个维度展开(颜色区分主题:蓝=本质、黄=原理、橙=对比、绿=实战、灰=相关指令)。蓝色分支讲「是什么」(两种写法、lazy、dirty),黄色分支讲「怎么工作」(Observer/Dep/Watcher/depend/notify),橙色分支讲「和谁有区别」,绿色分支讲「用在哪」,灰色分支收纳 v-cloak。面试时从任意一个节点都可向下钻取深入讲解,是复盘与口述讲解的速查地图。
高频面试题速查
| computed 和 methods 的区别? | computed 有缓存(dirty 标志),依赖不变时多次访问只执行一次 getter;methods 每次调用都执行,无缓存 |
| computed 和 watch 的区别? | computed 同步、有缓存、无副作用、不可异步,用于数据派生;watch 可异步、无缓存、可副作用,用于数据变化时的行为响应 |
| computed 的缓存是如何实现的? | 通过 Watcher 的 dirty 标志:初始为 true,evaluate() 执行后置 false;依赖变化时 dep.notify() 将 dirty 重置为 true |
| Vue2 依赖收集的原理? | Object.defineProperty 注入 getter/setter;getter 触发时 dep.depend() 将当前 Watcher 加入 subs;setter 触发时 dep.notify() 通知所有 Watcher |
| v-cloak 的作用和原理? | 解决插值表达式闪烁;配合 CSS [v-cloak]{display:none};Vue 挂载完成后自动移除该属性,元素恢复可见 |
| computed 可以写异步逻辑吗? | 不可以。computed getter 必须同步返回值,异步场景应改用 watch |
| 什么时候用 computed,什么时候用 watch? | 能用 computed 的优先用 computed;只有需要异步操作或执行副作用(修改 data 以外的事情)时才用 watch |
学习建议
动手验证缓存:将反转字符串示例中的 console.log 打印出来,亲眼看到 methods 调用 N 次而 computed 只调用 1 次,比读文字更有体感。
实现购物车:从零手写一个完整购物车(增删改查 + 全选 + 总价),不参考现成代码。这是巩固 computed 全部用法的最有效方式。
阅读源码:Vue2 的 computed 相关逻辑集中在 src/core/instance/state.js 的 initComputed 函数和 src/core/observer/watcher.js 的 Watcher 类。读懂 evaluate() 和 dirty 的联动,computed 的原理就完全清楚了。
对比 Vue3:Vue3 用 computed() 函数 API 替代了配置对象写法,但底层的 effect + trackEffect + triggerEffects 机制是 Vue2 Dep/Watcher 的进化版——学好 Vue2 的响应式原理,Vue3 的理解会快很多。
面试准备:「computed 和 watch 的区别」是高频考点,背答案不够,要能结合购物车、搜索框等具体业务场景来说,并能追加「为什么 computed 用 dirty 而不是直接重算」等原理层面的问题。
参考资料:
- Vue2 官方文档 – 计算属性和侦听器
- Vue2 官方文档 – v-cloak
- Vue2 源码 – Watcher 实现
- Vue2 源码 – initComputed

