欢迎光临
我们一直在努力

Vue2计算属性与侦听器深度解析

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 和 watch 的区别」几乎是 Vue 面试必问题,本文从原理到实践给出完整回答。
  • 框架基础:Vue2 的计算属性缓存机制与 React 的 useMemo、MobX 的 computed 背后原理一脉相承,掌握一种等于掌握了响应式系统的核心模式。
  • 生产力倍增:正确使用 computed 避免模板中的「计算爆炸」,是代码可维护性的关键。
  • 进入组件化的桥梁:computed 的 Watcher 机制与组件渲染 Watcher 共享同一套响应式基础设施,理解它是理解 Vue 组件更新的前提。

  • 一、计算属性 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 选项中定义。它的核心特点是:

  • 缓存结果:计算结果被缓存,相同依赖下多次访问只执行一次 getter。
  • 依赖追踪:自动追踪 getter 中访问的所有响应式属性,只有当这些依赖变化时才重算。
  • 惰性求值:计算属性在被访问之前不会执行 getter(lazy: true)。
  • 官方文档参考: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 中处理。

    【本章小结】

    写法gettersetterv-model 支持
    函数简写 ❌(尝试赋值会报警告)
    对象 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 的多种用法:

  • checkedCount:用 filter + reduce 聚合已勾选商品的件数,依赖每个商品的 checked 和 count。
  • originalTotal:计算已勾选商品的价格总和,结果格式化为两位小数。
  • discountInfo:依赖 originalTotal(而非原始 data),形成「computed 链式依赖」——这是 Vue2 完全支持的模式,每一层都有缓存。
  • finalTotal:依赖 discountInfo,进一步派生实付金额。
  • allChecked:使用 get/set 双向写法,get 检查是否所有商品都已勾选,set 批量修改所有商品的 checked 状态——这是全选功能的经典实现。
  • splice 用于删除商品而非 delete,是因为 splice 是 Vue2 劫持的数组变异方法,能触发响应式更新;而 delete arr[idx] 和下标赋值 arr[idx] = x 均无法触发响应式。市面应用:淘宝、京东、拼多多等电商平台的购物车页面均采用类似的 computed 链式计算模式。

    【实战要点】

    • 经典应用场景:购物车总价、折扣计算、优惠券适用性判断、分期付款金额——所有「从商品列表派生数值」的场景。
    • 常见坑:originalTotal 返回的是字符串(toFixed() 的返回类型),discountInfo 中需要 parseFloat(this.originalTotal) 转回数字再计算。切忌混淆字符串数字与数值类型的计算。
    • 性能最佳实践:computed 链(A 依赖 B,B 依赖 C)是高效的——每层都有自己的缓存,只有最上游数据变化才触发重算。不要将所有逻辑堆在一个巨型 computed 中,拆成多层有助于缓存命中和调试。

    【本章小结】

    购物车案例展示了 computed 四种核心能力:

  • 聚合计算(reduce 求和)
  • 条件派生(满减逻辑)
  • 链式依赖(computed 依赖另一个 computed)
  • 双向绑定(get/set 实现全选)
  • computed 能力购物车中的体现关键 API收益
    聚合计算 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 属性。
  • 在 CSS 中用属性选择器 [v-cloak] { display: none } 将带有该属性的元素隐藏。
  • Vue 挂载完成后自动移除 v-cloak 属性,元素恢复显示——此时内容已经是渲染后的真实数据。
  • 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
    赞(0)
    未经允许不得转载:171主机测评 » Vue2计算属性与侦听器深度解析
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址