Skip to content

计算属性(重点)与基本使用(Lesson3 3/3) ​

本页对应 Lesson03,共 5 段视频,约 56 分钟。

学习目标 ​

  1. 能够使用 v-for 渲染商品列表,并以商品 ID 作为唯一 key,正确展示商品名称、价格、上架时间和库存。
  2. 能够使用过滤器对商品价格进行保留两位小数和货币符号格式化,并对上架时间进行格式化显示。
  3. 能够声明计算属性统计购物车总金额,并在购买数量变化时自动更新,且在模板中以属性形式调用计算属性。
  4. 能够实现加入购物车逻辑:根据商品 ID 查找商品、判断库存、已有商品数量加一、新商品加入购物车,并同步扣减库存。
  5. 能够实现购物车列表的空状态提示、数量加减、单项删除和清空购物车,并在删除或清空时正确归还库存。
  6. 能够完成组件的定义、注册与使用,通过 props 向子组件传递商品数据,并说明组件拆分的基本方式。

知识点 ​

计算属性为什么需要:从购物车合计说起 ​

转写稿里的“计酸属性”“构物车”应理解为“计算属性”“购物车”。这一节用购物车案例引出计算属性:商品列表渲染后,购物车里要显示商品名称、单价、购买数量,还要统计合计。合计不是单个数据,而是由“购物车列表、每项单价、每项购买数量”共同决定的结果。如果直接在模板里写复杂表达式,代码会难读、难复用;如果写成普通方法,每次渲染都可能重新执行。计算属性正是为这种“逻辑复杂、结果受多个数据状态影响、需要缓存”的场景准备的。它相当于 React 中的 useMemo:依赖不变时复用结果,依赖变化时重新计算。

怎么想:先判断结果是否由多个响应式数据推导而来,且模板中要多次使用;如果是,就优先考虑计算属性。
怎么做:在 computed 中定义函数,模板中当属性用,不加括号。
易错点:不要把计算属性当普通方法调用成 total();也不要在计算属性里修改数据,它只负责“算”。

怎么想:计算属性的本质与缓存 ​

计算属性的本质是一个函数,但在模板中要作为属性使用。它会读取实例中的属性进行复杂逻辑运算,并把结果缓存起来。影响结果的实例属性发生改变时,计算属性函数会重新计算结果;如果未改变,就从缓存中提取结果。转写稿中合计由 cartList 里的 byName 和 goodsPrice 决定,因此这两个值变化时合计才需要重算。
为什么需要:避免模板里堆复杂表达式,避免重复计算。
怎么做:把合计写成一个计算属性,内部用 reduce 累加。
易错点:依赖的数据必须放在 data 等响应式位置;普通变量变化不会触发视图更新。

怎么做:定义合计计算属性 ​

按转写稿整理,合计逻辑可写成:

js
computed: {
  total() {
    return this.cartList.reduce((sum, item) => {
      return sum + item.byName * item.goodsPrice
    }, 0)
  }
}

模板中直接作为属性使用:

html
<p>合计:{{ total | currency }}</p>

转写稿中合计出现小数精度问题,于是又配合过滤器保留两位小数。这里要注意:计算属性负责“算结果”,过滤器负责“格式化显示”,两者职责不同。购物车为空时,转写稿用 v-if="cartList.length === 0" 显示“购物车空空如也”,有数据时再渲染列表。
易错点:模板里写 {{ total() }} 是错误用法;过滤器管道应作用在结果整体上,必要时给乘法表达式加括号,避免优先级问题。

调试过程:从报错定位到传参和拼写 ​

转写稿中点击“加入购物车”后控制台报错,读取商品库存相关属性时找不到对象。追查发现:模板调用方法时没有把商品 ID 传进去,方法里根据 ID 查找商品得到 undefined,再读库存就报错。修正为类似 @click="addCart(item.id)" 后,商品才能被找到。随后又出现 this.cartList is not iterable,原因是数据名拼写或使用不一致,把购物车列表写错,导致它不是可迭代数组。修正拼写后列表才能正常 v-for 渲染。这个调试过程说明:计算属性和方法都依赖正确的数据与参数;报错时先看“谁为 undefined”,再回到模板调用处检查传参,最后检查数据名拼写。

易错点:计算属性与 methods、过滤器的边界 ​

为什么需要区分:计算属性有缓存,方法默认每次调用都执行;过滤器只做显示格式化。
怎么想:需要缓存推导结果用 computed;需要执行动作、传参、改数据用 methods;只改显示格式用过滤器。
怎么做:合计用 computed,加入购物车、加减、删除、清空用 methods,金额格式用过滤器。
易错点:一是在计算属性里做“加购、删除”等副作用;二是把依赖写错,导致缓存不更新;三是模板中忘记传参;四是数据名拼写错误导致“不可迭代”。只要记住“计算属性只算不改、模板当属性用、依赖变才重算”,这一节的重点就抓住了。

本节要解决什么问题 ​

这一节用一个“字符串反转”的小例子,把 Vue 模板里常见的四种数据处理方式放在一起对比:插值表达式、函数、过滤器、计算属性。它们都能把 str 反转后显示出来,但适用场景、缓存行为、this 指向和可复用性不同。学习时不要只记写法,而要判断:这段逻辑简单还是复杂?会不会重复使用?需不需要缓存?要不要格式化输出?

插值表达式:简单运算直接写在模板里 ​

为什么需要:如果只是把数据做一次很短的运算再显示,直接写在模板里最直观。

怎么想:str 是字符串,反转就是先 split("") 拆成数组,再 reverse() 反转,最后 join("") 拼回字符串。

怎么做:在 {{ }} 中写表达式:

html
<p>{{str.split("").reverse().join("")}}</p>

数据变化时,插值表达式会重新计算。

易错点:表达式较简单且不会被重复使用时可以用;如果多处重复写,或逻辑较复杂,会让模板过重、阅读困难。模板里也不要写复杂语句,表达式应尽量短。

函数:把逻辑放进 methods 复用 ​

为什么需要:逻辑复杂、需要复用、需要传参时,应把计算过程抽到函数里。

怎么想:在 Vue 配置对象的 methods 中定义 reverseFn,函数内部通过 this.str 访问数据。因为普通函数的 this 指向 Vue 实例。

怎么做:

js
methods: {
  reverseFn() {
    return this.str.split("").reverse().join("");
  }
}

模板中调用:

html
<p>{{reverseFn()}}</p>

数据状态改变时函数会再次被调用;模板中调用几次,就会执行几次,没有缓存。

易错点:如果写成箭头函数,this 会指向 window,拿不到 Vue 实例数据。模板中调用函数要加括号;需要参数时不要漏传。

过滤器:做格式化展示,不负责改数据 ​

为什么需要:过滤器适合对展示内容做格式化,例如金额、时间、字符串样式处理,并且可以在多个地方复用。

怎么想:过滤器定义在 filters 对象中,属性名就是过滤器名,值是一个函数。第一个参数是要被过滤的数据,后面可以接收自定义参数,返回值就是视图中输出的内容。

怎么做:

js
filters: {
  reverseFilter(v) {
    return v.split("").reverse().join("");
  }
}

模板中用管道符:

html
<p>{{str | reverseFilter}}</p>

易错点:过滤器函数中的 this 指向 window,不能在过滤器里操作 Vue 实例状态;过滤器不是用来更新数据的,而是格式化输出;它没有缓存。

计算属性:依赖数据变化后缓存结果 ​

为什么需要:当一个结果由多个数据状态计算而来,并且模板中会重复使用时,计算属性更合适。它写起来像函数,使用时像属性,并且有缓存。

怎么想:计算属性定义在 computed 中,属性名就是计算属性名。函数只有一个形参,即 Vue 实例;使用时作为属性用,所以不能传参。依赖的数据不变时,重复使用不会重复执行;依赖变化时才重新计算。

怎么做:可以写成函数:

js
computed: {
  reverseCom(vm) {
    return this.str.split("").reverse().join("");
  }
}

也可以写成对象控制读写:

js
reverseCom: {
  get(vm) {
    return this.str.split("").reverse().join("");
  },
  set(v) {
    this.str = v.split("").reverse().join("");
  }
}

模板中直接写 {{reverseCom}},不要加括号。示例里 v-model="reverseCom" 会触发 set,把输入值反转后写回 str;get 返回 str 的反转结果。

易错点:计算属性不能像函数那样传参;不支持异步逻辑;模板中不要写成 reverseCom();set 中不要直接修改计算属性本身,而应修改它依赖的数据。

从转写稿看调试思路 ​

转写稿的购物车练习里出现过两个典型问题:点击加入购物车时条件不满足,追溯后发现模板调用方法时漏传了 item.id;另一个报错 this.colList is not iterable,检查发现是属性名拼写错误,把 cartList 写成了 colList。这类调试思路同样适用于本节:模板调用函数要检查括号和参数;过滤器、计算属性要检查名称和依赖;库存判断用“小于 1”比“等于 0”更稳妥,避免异常负数。遇到报错先看控制台定位方法,再在方法里输出关键数据,逐步回溯到模板调用处。

完整示例代码 ​

html
<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<title>Title</title>
	<script src="lib/vue.js"></script>
</head>
<body>
	<div id="app">
		<input type="text" v-model="str">
		
		<!--
			插值表达式的运算是Vue模块中的语法。
			如果表达式较简单且不会被重复使用建议采用插值表达式。
			如果需要在多处需要进行计算的话,那么不建议。
			如果程序较复杂,那么会让Vue模板过重,阅读困难,不建议。
			如果数据发生改变,插值表达式也会重新计算。
		-->
		<h3>插值表达式</h3>
		<p>{{str.split("").reverse().join("")}}</p>
		<p>{{str.split("").reverse().join("")}}</p>
		<hr/>
		<!--
			函数是定义在methods属性中.
			可以被重复使用,可以书写复杂的逻辑。
			可以传递参数。
			函数中的this指向Vue实例.
			如果函数设置为箭头函数,那么this指向的是window
			如果数据状态发生改变,那么函数会被再次调用。
			如果函数在模块中调用多次,那么当数据状态发生改变,那么调用多次。
			没有缓存。
		-->
		<h3>函数</h3>
		<p>{{reverseFn()}}</p>
		<p>{{reverseFn()}}</p>
		<hr/>
		<!--
			1- 过滤器是通过Vue配置对象下的filters对象来定义的
			2- filters对象的属性名即是过滤器的名字
			3- 过滤器的类型是一个函数
			4- 过滤器接收的第一个参数是要被过滤的数据,后面参数是自定义的,返回的结果是要在视图中输出的内容
			5- 过滤器在使用时需要写在视图中的管道符的右侧。也可以额外传递参数。
			6- 过滤器函数中的this指向的是window.在过滤器中无法操作Vue实例中的数据状态。
			7- 应用场景:对数据进行格式化处理,操作的数据不同,可以重复使用。(不是更新数据的)
			8- 没有缓存。
		-->
		<h3>过滤器</h3>
		<p>{{str | reverseFilter}}</p>
		<p>{{str | reverseFilter}}</p>
		<hr/>
		<!--
			1- 计算属性是通过在Vue配置对象中的computed对象中定义的
			2- computed对象的属性名即是计算属性的名字
			3- 计算属性的本质是一个函数(也可以是对象控制读写),但要作为属性来使用.
			4- 计算属性只有一个形参,即Vue实例。在使用时由于作为属性来使用,所以无法传递参数。
			5- 本质:写的是函数,但是Vue会将该函数执行,并将其结果进行缓存,并作为当前实例的属性来处理。
			6- 函数的返回值即是输出的内容(计算属性的值)
			7- 关于缓存:计算属性的结果可以被一个或多个数据状态控制,只要有一个数据状态发生改变,那么计算属性会被重新执行(即重新计算结果)。
		    8- 应用场景:偏向于某一数据的结果(由不同的数据状态计算)
		    9- 不支持异步逻辑
		-->
		<h3>计算属性</h3>
		<input type="text" v-model="reverseCom">
		<p>{{reverseCom}}</p>
		<p>{{reverseCom}}</p>
		<p>{{reverseCom}}</p>
		<p>{{reverseCom}}</p>
		<p>{{reverseCom}}</p>
	</div>
</body>
<script>
	new Vue({
		el:"#app",
		data:{
			str:"我现在在上海!"
		},
		methods:{
			reverseFn(){
				// console.log("reverseFn被调用啦!")
				return this.str.split("").reverse().join("");
			}
		},
		filters:{
			reverseFilter(v){
				// console.log("reverseFilter",this)
				return v.split("").reverse().join("")
			}
		},
		computed:{
			// 1- 函数
			// reverseCom(vm){
			// 	console.log("reverseCom",this===vm);// true
			// 	return this.str.split("").reverse().join("");
			// }
			
			// 2- 对象-读写
			reverseCom:{
				// 接收的参数是Vue实例,返回的结果即是该计算属性的结果
				get(vm){
					console.log(this===vm)
					return this.str.split("").reverse().join("");
				},
				// 在修改计算属性时执行,接收的参数即是修改的值
				set(v){
					this.str = v.split("").reverse().join("");
				}
			}
		}
	})
</script>
</html>

先搭商品列表:数据驱动视图 ​

为什么需要:购物车的第一步不是写“加购”,而是先把商品数据稳定地渲染出来。只有商品来自 data,后续库存、价格、加购才有统一的数据源。怎么想:用 goodsList 数组描述商品,每项包含 id、goodsName、goodsPrice、addTime、stock;模板里用 v-for 渲染,并给每个 :key 绑定唯一 id。怎么做:

js
data() {
  return {
    goodsList: [
      { id: 1, goodsName: '手机', goodsPrice: 12.23, addTime: new Date(), stock: 10 },
      { id: 2, goodsName: '笔记本', goodsPrice: 24.56, addTime: new Date(), stock: 20 }
    ],
    cartList: []
  }
}
html
<h3>商品列表</h3>
<div v-for="item in goodsList" :key="item.id">
  <span>{{ item.goodsName }}</span>
  <span>{{ item.goodsPrice | _currency }}</span>
  <span>{{ item.addTime | _date }}</span>
  <span>库存:{{ item.stock }}</span>
  <button @click="addToCart(item.id)">加入购物车</button>
</div>

易错点:v-for 建议写 :key,转写里也提醒“有增删操作更建议解 K”,这里应写成 :key,不要写成 :key 之外的自造属性;数据必须放在 data 里,否则不能驱动视图。

过滤器格式化价格与时间 ​

为什么需要:价格直接显示会出现 12.23、24.56 这类小数精度和货币符号问题,时间直接显示也不友好。怎么想:把格式化逻辑抽成过滤器,模板只负责展示。怎么做:沿用之前写过的 _currency、_date 过滤器,价格和时间通过管道使用。易错点:如果合计是“数量 × 单价”的表达式,建议先加括号再过滤,例如 {{ (item.buyNum * item.goodsPrice) | _currency }},否则管道作用对象容易看错。转写里现场就是先发现合计小数不对,再补过滤器和括号。

加入购物车:先判断库存再合并数量 ​

为什么需要:加购不是简单 push,要处理库存不足、同一商品重复加购。怎么想:点击按钮传商品 id,先按 id 找到商品,再判断库存;库存不足提示并 return;库存充足时检查购物车中是否已有该商品:有则购买数量加一,无则新增一条,最后库存减一。怎么做:

js
methods: {
  addToCart(id, isAdd = true) {
    const goodsItem = this.goodsList.find(v => v.id === id)
    if (goodsItem.stock < 1) {
      alert('库存不足')
      return
    }
    const cartItem = this.cartList.find(v => v.id === id)
    if (cartItem) {
      cartItem.buyNum++
    } else {
      this.cartList.push({
        id: goodsItem.id,
        goodsName: goodsItem.goodsName,
        goodsPrice: goodsItem.goodsPrice,
        buyNum: 1
      })
    }
    goodsItem.stock--
  }
}

易错点:转写里调试时出现 goodsItem 读不到属性,原因是模板只写了 addToCart() 没传 item.id,find 返回 undefined;另一个报错是 this.colList is not iterable,本质是 cartList 拼写错误。库存判断建议写 stock < 1,比只写 === 0 更稳,避免异常负数。

购物车渲染与合计:计算属性 + 过滤器 ​

为什么需要:购物车要显示商品名、单价、购买数量,还要统计总价;总价由购物车内容决定,适合计算属性。怎么想:v-if="cartList.length === 0" 显示“购物车空空如也”,v-else 渲染列表;合计用 reduce。怎么做:

html
<h3>购物车</h3>
<h3 v-if="cartList.length === 0">购物车空空如也</h3>
<div v-else v-for="item in cartList" :key="item.id">
  {{ item.goodsName }} / {{ item.goodsPrice | _currency }} / 数量:{{ item.buyNum }}
</div>
<h5>合计:{{ total | _currency }}</h5>
js
computed: {
  total() {
    return this.cartList.reduce((sum, item) => {
      return sum + item.buyNum * item.goodsPrice
    }, 0)
  }
}

易错点:reduce 初始值要写 0;模板里不要漏掉 item. 前缀。

加减数量:复用加入逻辑并区分方向 ​

为什么需要:加号效果与“加入购物车”一致,减号则要减少数量、数量为 0 时移除并归还库存。怎么想:加号直接调用 addToCart(item.id);减号调用 addToCart(item.id, false),在方法内用 isAdd 区分。减法的步骤是:按 id 找到购物车项,buyNum--;若 buyNum <= 0 就过滤掉该条;最后商品库存加一。易错点:减号不要也调用成加法;过滤删除时注意保留“不等于当前 id”的项。

删除与清空:归还库存再移除 ​

为什么需要:删除购物车条目时,之前占用的库存要还回商品列表;清空则要逐条归还。怎么想:删除前先找到购物车项和商品,执行 goodsItem.stock += cartItem.buyNum,再用 filter 移除;清空则遍历 cartList 逐条归还,最后 cartList = []。怎么做:

js
delCart(id) {
  const cartItem = this.cartList.find(v => v.id === id)
  const goodsItem = this.goodsList.find(v => v.id === id)
  if (!cartItem || !goodsItem) return
  if (!confirm(`确定要删除${cartItem.goodsName}吗?`)) return
  goodsItem.stock += cartItem.buyNum
  this.cartList = this.cartList.filter(v => v.id !== id)
}

易错点:删除前要确认,转写里加了 confirm;清空前也要提示。删除和清空真正开发会走接口,这里主要练习 Vue 基本语法和 JS 逻辑。

先把需求拆成两块 ​

这一节表面上叫“组件”,实际练的是一组贯穿 Vue 基础的功能:商品列表 + 购物车。它把 v-for、v-if/v-else、过滤器、计算属性、事件绑定和数组方法全部串了一遍。

为什么需要这样练?因为只背语法不会用。需求先定清楚:上面是商品区,每个商品有名称、价格、上架时间、库存和一个“加入购物车”按钮;下面是购物车区,点一次按钮,商品进购物车,购买数量加 1,同时商品库存减 1;购物车为空时给提示;最后统计合计金额,并支持加减、删除、清空。

怎么想?先别写代码,先写步骤。转写稿里讲师反复强调“思路先写一写”:点击按钮 → 接收商品 ID → 根据 ID 找商品 → 判断库存是否充足 → 充足则看购物车是否已有该商品 → 有就数量加 1,没有就新增一条 → 最后库存减 1。这条链路就是整个功能的骨架。

商品列表与 key ​

数据必须放在 data 里,因为只有 data 里的数据变化才能驱动视图更新。商品列表写成数组,每个元素是一个对象:

js
data() {
  return {
    goodsList: [
      { id: 1, goodsName: '手机', price: 12.23, addTime: ..., stock: 10 },
      { id: 2, goodsName: '笔记本', price: 24.56, addTime: ..., stock: 20 }
    ],
    cartList: []
  }
}

模板里用 v-for 渲染,并且要加 :key="item.id"。转写稿里讲师说得很直白:有的地方写 key、有的地方忘了写,建议统一加上,尤其是后面有增删操作时,key 能帮助 Vue 正确复用节点。

易错点:v-for 里取属性要用 item.goodsName,不要漏掉 item.;价格和时间不要急着在模板里拼字符串,后面交给过滤器。

过滤器:价格与时间格式化 ​

为什么需要过滤器?因为价格要保留两位小数、时间要显示成可读格式,这些是“展示层”的转换,不该塞进 data。做法是把过滤器写在 filters 里,模板中这样用:

html
{{ item.price | currency }}
{{ item.addTime | date }}

易错点:过滤器只负责格式化,不改变原数据;多个过滤器可以用管道串起来,但别把业务计算也写进过滤器。

加入购物车:先判断,再写入 ​

核心方法如下,思路和转写稿一致:

js
addCart(id) {
  const goods = this.goodsList.find(v => v.id === id)
  if (goods.stock < 1) return alert('库存不足')

  const cart = this.cartList.find(v => v.id === id)
  if (cart) {
    cart.buyNum++
  } else {
    this.cartList.push({
      id: goods.id,
      goodsName: goods.goodsName,
      price: goods.price,
      buyNum: 1
    })
  }
  goods.stock--
}

这里有两个判断值得注意。第一,库存判断用 stock < 1,而不是只写 stock === 0。转写稿里讲师讲了自己的教训:只判断等于 0,一旦库存被改成负数,逻辑就漏了。第二,购物车判断用 find,找到就加数量,找不到才 push 新对象;如果无脑 push,同一商品会出现多条记录。

调试过程也很典型:第一次点击报错,说读取某个属性时找不到。讲师先 console.log(goods),发现是 undefined,再往前追,发现模板里调用方法时没把 item.id 传进去。补上参数后又报 this.cartList is not iterable,检查发现是 cartList 拼写成了别的名字。这两个错误都不是“Vue 的问题”,而是传参和拼写问题。

购物车列表、空态与合计 ​

购物车用 v-for 渲染 cartList,显示名称、单价、购买数量。空态用 v-if 判断 cartList.length === 0,显示“购物车空空如也”,否则渲染列表。

合计金额用计算属性,因为它是由 cartList 推导出来的:

js
computed: {
  total() {
    return this.cartList.reduce(
      (sum, item) => sum + item.buyNum * item.price, 0
    )
  }
}

模板里再套货币过滤器:{{ total | currency }}。易错点:reduce 一定要给初始值 0;乘法结果要先算成一个整体再交给过滤器,否则精度和格式都可能不对。

加减、删除与清空 ​

加号可以直接复用加入购物车的方法,因为“点加号”和“再点一次加入购物车”效果一样。减号则要单独处理:先根据 ID 找到购物车记录,buyNum--;如果减到 0,就用 filter 把这条记录删掉;最后别忘了把商品库存加回去。

删除同理:先找到购物车记录,把 buyNum 归还给对应商品的库存,再 filter 掉该条记录。删除前加一个 confirm 提示,体验更完整。

清空购物车则是遍历 cartList,把每条记录的购买数量归还库存,最后把 cartList 置为空数组。易错点:清空不是只清数组,库存也要一起归还;另外遍历时不要一边遍历一边改原数组,先归还再统一置空更稳妥。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

一、计算属性基本使用(插值 / 函数 / 过滤器 / 计算属性 对比) ​

html
<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<title>计算属性</title>
	<script src="lib/vue.js"></script>
</head>
<body>
	<div id="app">
		<input type="text" v-model="str">

		<!-- 插值表达式:简单逻辑可以,重复写、逻辑复杂就不建议 -->
		<h3>插值表达式</h3>
		<p>{{str.split("").reverse().join("")}}</p>
		<p>{{str.split("").reverse().join("")}}</p>
		<hr/>

		<!-- 函数:可复用、可传参,但没有缓存,模板里调用几次就执行几次 -->
		<h3>函数</h3>
		<p>{{reverseFn()}}</p>
		<p>{{reverseFn()}}</p>
		<hr/>

		<!-- 过滤器:用于格式化,this 指向 window,不能操作实例状态,无缓存 -->
		<h3>过滤器</h3>
		<p>{{str | reverseFilter}}</p>
		<p>{{str | reverseFilter}}</p>
		<hr/>

		<!-- 计算属性:本质是函数,但作为属性使用,结果被缓存 -->
		<h3>计算属性</h3>
		<input type="text" v-model="reverseCom">
		<p>{{reverseCom}}</p>
		<p>{{reverseCom}}</p>
		<p>{{reverseCom}}</p>
		<p>{{reverseCom}}</p>
		<p>{{reverseCom}}</p>
	</div>
</body>
<script>
	new Vue({
		el: "#app",
		data: {
			str: "我现在在上海!"
		},
		methods: {
			reverseFn() {
				return this.str.split("").reverse().join("");
			}
		},
		filters: {
			reverseFilter(v) {
				return v.split("").reverse().join("")
			}
		},
		computed: {
			// 1- 函数写法:只读,无法被 v-model 赋值
			// reverseCom(vm){
			// 	return this.str.split("").reverse().join("");
			// }

			// 2- 对象写法:get 取值,set 赋值
			reverseCom: {
				get(vm) {
					return this.str.split("").reverse().join("");
				},
				set(v) {
					this.str = v.split("").reverse().join("");
				}
			}
		}
	})
</script>
</html>

关键行与易错点

  • computed 里写的是函数,模板里要当属性用({{reverseCom}}),不能写 {{reverseCom()}},也不能传参——它只有一个形参 vm(Vue 实例)。
  • 缓存验证:把 str 改成不变的值再改回来,只有第一次重新计算;methods 里的 reverseFn 每渲染一次就调用一次,所以同一份逻辑在模板里写 5 遍,计算属性只算 1 次。
  • 想被 v-model 绑定,必须写成 对象 get/set 形式;如果只写函数形式,v-model="reverseCom" 会报 “no setter” 错误。
  • 过滤器里的 this 指向 window,所以在 reverseFilter 里拿不到 this.str,只能靠入参 v。
  • 计算属性不支持异步逻辑;适用场景是“结果由多个数据状态计算得出且需要缓存”。

二、购物车(商品列表 + 加入购物车 + 加减 + 删除 + 清空 + 合计) ​

html
<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<title>购物车</title>
	<script src="lib/vue.js"></script>
	<style>
		table { border-collapse: collapse; margin: 10px 0; }
		td, th { border: 1px solid #ccc; padding: 6px 12px; }
	</style>
</head>
<body>
	<div id="app">
		<!-- 第一部分:商品列表 -->
		<h3>商品列表</h3>
		<table>
			<tr>
				<th>名称</th><th>单价</th><th>上架时间</th><th>库存</th><th>操作</th>
			</tr>
			<tr v-for="item in goodsList" :key="item.id">
				<td>{{item.goodsName}}</td>
				<td>{{item.price | _price}}</td>
				<td>{{item.addTime | _date}}</td>
				<td>{{item.storeName}}</td>
				<td>
					<button @click="addCart(item.id)">加入购物车</button>
				</td>
			</tr>
		</table>

		<h5>合计:{{total | _price}}</h5>
		<hr/>

		<!-- 第二部分:购物车列表 -->
		<h3>购物车</h3>

		<!-- 购物车为空 -->
		<div v-if="cartList.length === 0">
			<h3>购物车空空如也</h3>
		</div>

		<!-- 购物车有数据 -->
		<div v-else>
			<table>
				<tr>
					<th>名称</th><th>单价</th><th>购买数量</th><th>小计</th><th>操作</th>
				</tr>
				<tr v-for="item in cartList" :key="item.id">
					<td>{{item.goodsName}}</td>
					<td>{{item.price | _price}}</td>
					<td>
						<button @click="addCart(item.id, false)">-</button>
						{{item.buyNum}}
						<button @click="addCart(item.id, true)">+</button>
					</td>
					<td>{{(item.price * item.buyNum) | _price}}</td>
					<td>
						<button @click="delCartById(item.id)">删除</button>
					</td>
				</tr>
			</table>
			<button @click="clearCart">清空购物车</button>
		</div>

		<h5>合计:{{total | _price}}</h5>
	</div>
</body>
<script>
	new Vue({
		el: "#app",
		data: {
			// 商品列表
			goodsList: [
				{ id: 1, goodsName: '手机', price: 1223, addTime: new Date(), storeName: 10 },
				{ id: 2, goodsName: '笔记本', price: 2456, addTime: new Date(), storeName: 20 },
				{ id: 3, goodsName: '平板', price: 3456, addTime: new Date(), storeName: 15 }
			],
			// 购物车列表:id / goodsName / price / buyNum
			cartList: []
		},
		filters: {
			// 货币格式化
			_price(v) {
				return '¥' + Number(v).toFixed(2);
			},
			// 时间格式化
			_date(v) {
				const d = new Date(v);
				const pad = n => (n < 10 ? '0' + n : n);
				return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
					+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
			}
		},
		methods: {
			// 加入购物车 / 加减(isAdd 为 true 表示加,false 表示减)
			addCart(id, isAdd = true) {
				if (isAdd) {
					// 1. 根据 ID 找到商品
					const goodsInfo = this.goodsList.find(v => v.id === id);
					if (!goodsInfo) return;

					// 2. 判断库存是否充足(小于 1 一律视为不足,比 ===0 更安全)
					if (goodsInfo.storeName < 1) {
						alert('库存不足');
						return;
					}

					// 3. 判断购物车中是否已有该商品
					const cartInfo = this.cartList.find(v => v.id === id);
					if (cartInfo) {
						// 有:购买数量 +1
						cartInfo.buyNum += 1;
					} else {
						// 无:为购物车新增一条记录,购买数量为 1,最后加购的放最上面
						this.cartList.unshift({
							id: goodsInfo.id,
							goodsName: goodsInfo.goodsName,
							price: goodsInfo.price,
							buyNum: 1
						});
					}

					// 4. 库存 -1
					goodsInfo.storeName -= 1;
				} else {
					// 减法:先找到购物车信息
					const cartInfo = this.cartList.find(v => v.id === id);
					if (!cartInfo) return;

					// 购买数量 -1
					cartInfo.buyNum -= 1;

					// 减到 0 就把这条记录清除
					if (cartInfo.buyNum <= 0) {
						this.cartList = this.cartList.filter(v => v.id !== id);
					}

					// 库存 +1
					const goodsInfo = this.goodsList.find(v => v.id === id);
					if (goodsInfo) goodsInfo.storeName += 1;
				}
			},
			// 根据 ID 删除购物车信息
			delCartById(id) {
				const cartInfo = this.cartList.find(v => v.id === id);
				if (!cartInfo) return;
				if (!confirm('确定要删除' + cartInfo.goodsName + '吗?')) return;

				// 先把购买数量还给库存
				const goodsInfo = this.goodsList.find(v => v.id === id);
				if (goodsInfo) goodsInfo.storeName += cartInfo.buyNum;

				// 再删除该条记录
				this.cartList = this.cartList.filter(v => v.id !== id);
			},
			// 清空购物车
			clearCart() {
				if (!confirm('您确定要清空购物车吗?')) return;

				// 逐条把购买数量归还库存
				this.cartList.forEach(cartInfo => {
					const goodsInfo = this.goodsList.find(v => v.id === cartInfo.id);
					if (goodsInfo) goodsInfo.storeName += cartInfo.buyNum;
				});

				this.cartList = [];
			}
		},
		computed: {
			// 合计:由 cartList 决定,任一商品数量变化都会重新计算
			total() {
				return this.cartList.reduce((sum, item) => {
					return sum + item.price * item.buyNum;
				}, 0);
			}
		}
	})
</script>
</html>

关键行与易错点

  • @click="addCart(item.id)" 必须传 ID,只写 addCart 或忘传参会报 Cannot read property 'storeName' of undefined(转写稿里现场就踩了这个坑)。
  • 加减共用一个方法:addCart(item.id, false) 表示减,默认参数 isAdd = true 表示加,模板里加号传 true 或不传。
  • 判断库存用 goodsInfo.storeName < 1,不要用 === 0——负数同样要拦住(转写稿里讲到被改成负数导致损失的经历)。
  • 修改数组元素用 cartList.unshift(...) 或 cartList = cartList.filter(...);整体替换数组 Vue 才能稳定追踪,用 splice 删除也可以,但 this.cartList = this.cartList.filter(...) 更直观。
  • 删除、清空之前都要先归还库存,顺序反了(先删记录再找数量)就拿不到 buyNum 了。
  • 过滤器只负责“显示格式化”:{{(item.price * item.buyNum) | _price}} 里的乘法必须加括号,否则管道符只作用于 item.buyNum。
  • total 用 reduce 求累计值,初始值给 0;它是计算属性,购物车任一 buyNum 变化都会自动重算,不需要手动 watch。
  • v-if="cartList.length === 0" 与 v-else 配对使用,别在中间插入其他节点(如 <h5>),否则 v-else 会失效。

常见错误 ​

1. 购物车数组没在 data 里声明,点击加购页面毫无反应

  • 现象:控制台提示 Cannot read properties of undefined,或方法里 this.cartList = [] 后模板依旧不渲染。
  • 原因:Vue 2 只对 data 初始化时就存在的属性做响应式;运行时新增的属性没有 getter/setter,改了也不触发视图更新。
  • 修法:在 data(){ return { cartList: [] } } 中预先声明;确需后加属性用 this.$set(obj, key, val)。

2. 变量名拼错,报 xxx is not iterable / of undefined

  • 现象:点击按钮报错,报错行指向 v-for 或 filter。
  • 原因:data 里叫 cartList,方法或模板里写成 colList、callList,取到 undefined 再去遍历。
  • 修法:全局统一命名;报错时先 console.log(this) 看实际属性名,再逐行核对模板与方法。

3. 事件绑定漏传参,id 为 undefined

  • 现象:点"加入购物车"没效果,报 reading 'storeName' of undefined。
  • 原因:写成 @click="addCart"(传进去的是事件对象)而不是 @click="addCart(item.id)";find 返回 undefined 后仍直接取属性。
  • 修法:绑定处显式传 item.id;方法入口加 if(!goods) return 兜底。

4. 库存判断只写 === 0

  • 现象:库存归零后仍能继续加购,库存出现 -1、-2。
  • 原因:等值判断漏掉负数或脏数据;实际业务中库存可能已被其他流程改动。
  • 修法:统一用 store < 1 判不足;加购前判断、加购后再校验一次,双保险。

5. 计算属性当函数用,或在其中写异步/副作用

  • 现象:{{ total() }} 报 total is not a function;在 computed 里发请求或改 this 数据导致死循环、值不更新。
  • 原因:computed 定义的是 getter,模板中按属性访问;它是同步纯求值,依赖变化即重算。
  • 修法:模板写 {{ total }};需要传参的改用 methods 或让 computed 返回一个函数;异步逻辑放 created/methods。

6. 过滤器里用 this 访问实例数据

  • 现象:过滤器内 this.xxx 为 undefined,格式化结果错误。
  • 原因:filters 中 this 指向 window(严格模式下为 undefined),拿不到 Vue 实例。
  • 修法:数据一律通过参数传入;需要多个依赖参与计算时改用 computed。

7. 金额直接相乘,出现 73.67999999

  • 现象:合计显示一长串小数。
  • 原因:JS 浮点精度问题,且未在展示层做格式化。
  • 修法:展示层统一 toFixed(2) 或走货币过滤器,计算属性里保留原始数值,不做破坏性取整。

练习 ​

1. 满减合计 用计算属性算出原价合计 total,再写一个 payable 实现"满 100 减 10、满 200 减 30",模板用货币过滤器输出两位小数。 验收:加减任意商品时两个值同步变化,页面无 NaN。

2. 数量输入框双向同步 把购物车每行数量改成 <input type="number" v-model.number>,要求修改数量时同步扣减/归还库存,数量减到 0 时该行自动移除。可用 computed 的 get/set,也可用 change 事件。 验收:手输 5 后库存对应减少,输 0 后该行消失且库存已归还。

3. 派生列表不改原数组 写一个 computed 返回"数量 > 0 且库存充足"的购物车项,并按小计降序排列;要求原 cartList 长度与顺序不变(用 slice/concat/展开)。 验收:渲染前后 console.log(this.cartList) 输出完全一致。

4. 空态、确认与删除还原 补齐三件事:购物车为空时显示提示、单行删除前 confirm 并归还库存、清空购物车前 confirm 并全部归还库存。 验收:清空后合计为 0、空态出现、所有商品库存回到初始值。

5. 缓存对比实验 把"合计"分别用 methods 和 computed 各实现一份,在模板中各调用 3 次,点击加/减并观察 console 打印次数,把结论写进注释。 验收:能说清为什么方法打印 3 次、计算属性只打印 1 次。

原视频对照 ​

#视频时长
1商品列表渲染07:19
2加入购物车23:50
3购物车列表08:56
4加减操作07:02
5清空购物车08:52

小结 ​

  1. 购物车里的合计、件数、可结算项这类"由多个状态派生、多处复用"的数据,一律用 computed,不要用 methods 或模板内表达式。
  2. computed 有缓存,只在依赖变化时重算;methods 每次渲染都执行——这正是模板调用三次方法打印三次、调用三次计算属性只打印一次的原因。
  3. 计算属性默认只读,需要被 v-model 回写时必须补 set,且 set 里只做"反向换算",不要在里面再读自己。
  4. filters 只承担展示层格式化(货币、时间),this 不指向实例、无缓存、无法参与状态运算,不能拿来替代 computed。
  5. 购物车的增删改必须成对维护"购买数量 ↔ 商品库存":加购减库存、减号加库存、删除与清空都要逐项归还。
  6. 库存判断用 < 1 而不是 === 0,防止脏数据把库存改成负数后仍能继续加购。
  7. 数组的响应式更新走 push/splice/filter 重新赋值或 $set,避免下标直接赋值和修改 length。
  8. 数据在 data 中预先声明、命名全局统一、事件绑定显式传参、方法入口做空值兜底——这四条能省掉大半的排错时间。