2. 事件对象:从“会用”到“懂它”
2.1 $event 到底是什么
很多刚接触 Vue 的同学,第一次看到$event是在模板里写@click="handleClick($event)",然后 console.log 出来发现是个 MouseEvent,就以为“事件对象 = 原生事件对象”,其实这个理解只对了一半。
Vue 的事件绑定机制是在原生 DOM 事件之上做了一层封装。比如@click="handleClick",Vue 会帮你绑定一个真正的原生 click 监听器,但调用你传入的方法时,会做参数处理。如果你在模板里写了$event,它传的就是原生事件对象;如果没写,而组件方法只接收一个参数,Vue 也会默认把原生事件对象传进去。这就是为什么很多人写@click="handleClick"然后在方法里console.log(event)也能看到事件对象。
<template> <button @click="handleClick">点击我</button> </template> <script> export default { methods: { handleClick(event) { // 这个 event 就是原生事件对象 console.log(event.target) // 触发事件的 DOM 元素 console.log(event.currentTarget) // 绑定事件的 DOM 元素 } } } </script>不过这里有个很容易踩的坑:event.target和event.currentTarget的区别。target 是最初触发事件的元素,currentTarget 是当前绑定事件的元素。如果你在按钮内部还嵌套了一个<span>,点击 span 时,target 是 span,currentTarget 是 button。在事件委托场景下,这个区别尤其关键。
还有一点,Vue 2 中如果@click="handleClick"这样写,你拿到的确实是原生事件对象;但在 Vue 3 中,事件绑定的底层实现变了,但模板层面你依然能拿到原生事件。也就是说,你在模板里通过$event拿到的,始终是浏览器原生事件对象,这一点 Vue 做了兼容,开发者不需要关心细节。
2.2 事件修饰符为什么能简化逻辑
Vue 提供了一组事件修饰符,目的就是让你在模板里直接声明事件的转发规则,而不用在方法里写一堆event.preventDefault()、event.stopPropagation()之类的代码。我自己在实际项目里的感受是:修饰符用得好,方法里的代码会干净非常多。
常见的几个修饰符:
.stop:阻止事件冒泡,相当于调用event.stopPropagation().prevent:阻止默认行为,相当于调用event.preventDefault().capture:把事件监听转换为捕获阶段触发.self:只在事件目标是当前元素自身时触发.once:事件只触发一次.passive:告诉浏览器不会调用 preventDefault,提升滚动性能
<!-- 传统写法 --> <button @click="handleClick($event)">点击</button> // 方法里写 handleClick(event) { event.stopPropagation() event.preventDefault() // 其他逻辑... } <!-- 修饰符写法 --> <button @click.stop.prevent="handleClick">点击</button>第二种写法的优势不仅仅是省代码,更重要的是逻辑表达更贴近“声明式”:你一眼就能看出来这个按钮的行为是“阻止冒泡+阻止默认动作”,不需要去方法里找事件处理逻辑。对于协作开发来说,这种视觉化的信息传递效率很高。
需要注意.stop和.self的区别。.self是“只有点击自己才触发”,.stop是“自己触发后阻断往上冒泡”。如果事件是在子元素上触发的,即使父元素写了.self,父元素的监听器也不会触发,但事件仍然会继续向上冒泡到更外层。这个区别容易混淆,务必注意。
还有.once,这个修饰符在表单提交按钮上特别有用。防止用户连续点击导致重复提交,与其在方法里做标志位,不如直接用.once。但要注意,.once是让事件只触发一次,不是让按钮只可点击一次,按钮样式上还是要配合disabled做配合。
2.3 事件冒泡和捕获的机制剖析
说到事件对象,就不得不提事件的传播机制。浏览器的事件传播分三个阶段:捕获阶段、目标阶段、冒泡阶段。
- 捕获阶段:从 window 往下,依次经过每个祖先元素,最后到达目标元素
- 目标阶段:事件到达目标元素
- 冒泡阶段:从目标元素往上,依次经过每个祖先元素,最后回到 window
Vue 默认监听的是冒泡阶段的触发。所以当你点击一个子元素时,父元素上的 click 事件也会被触发,这就是冒泡。如果你不想让父元素被触发,就需要.stop。
那捕获阶段有什么用?在一些场景下,你希望事件在到达目标元素之前就被拦截,而不是等它冒泡回来再处理。比如全局点击埋点、权限拦截等。此时可以用.capture修饰符。
<div @click.capture="handleCapture"> <button @click="handleButton">按钮</button> </div>这段代码中,点击按钮时,handleCapture会先于handleButton触发。这在某些场景下很有用,比如你想在事件到达业务组件之前先做登录校验,校验不通过直接拦截。
理解冒泡机制还有一个实际的好处:写事件委托时心里有数。比如一个列表,如果给每个子项都绑定一个监听器,性能不好;更优的做法是把监听器绑定在父元素上,通过event.target判断点击的是哪一项。配合 Vue 的$event,你可以这样实现:
<ul @click="handleListClick"> <li v-for="item in list" :key="item.id" :data-id="item.id"> {{ item.name }} </li> </ul> <script> export default { data() { return { list: [ { id: 1, name: '苹果' }, { id: 2, name: '香蕉' }, { id: 3, name: '橘子' } ] } }, methods: { handleListClick(event) { // 通过 closest 判断点击的元素是 li const li = event.target.closest('li') if (li) { console.log('点击了', li.dataset.id) } } } } </script>事件委托的好处是无论列表项怎么增删,都不需要重新绑定事件,对性能很有帮助。尤其是列表数据量大或者数据频繁变化的场景,这个方案比每个 li 单独绑定@click更可靠,也更省内存。
3. 计算属性:一个“会记答案”的函数
3.1 计算属性到底解决了什么问题
先想一个场景:一个商品列表,每个商品有单价和数量,页面要显示每个商品的小计、所有商品的总价,还要显示哪些商品“金额超过100元”。如果不用计算属性,你可能会在methods里定义几个函数来处理。
methods: { getSubtotal(item) { return item.price * item.quantity }, getTotal() { return this.cart.reduce((sum, item) => sum + item.price * item.quantity, 0) }, isExpensive(item) { return item.price * item.quantity > 100 } }模板里调用时,就变成了:
<span>{{ getSubtotal(item) }}</span> <span>总价:{{ getTotal() }}</span> <span v-if="isExpensive(item)">金额较高</span>这个方法能跑,但有几个问题:
- 每次 getTotal() 被调用,哪怕影响总价的 item 数据没变化,也会重新执行一次。如果购物车有几百个商品,页面每渲染一次就要算一次总数,性能开销不小。
- 模板里夹杂太多方法调用,逻辑不清晰。
- 在 Vue 的响应式系统中,方法调用不会缓存结果,只要组件重新渲染,函数就会被重新执行。
计算属性就是来解决这些问题的。它的核心特点是:有缓存,依赖变了才重算。
computed: { totalPrice() { return this.cart.reduce((sum, item) => sum + item.price * item.quantity, 0) } }模板里写{{ totalPrice }},不需要加括号。Vue 会自动跟踪这个计算属性依赖的响应式数据(这里是 cart 数组以及每个商品的 price、quantity),当这些数据没有变化时,即使组件其他地方重新渲染,totalPrice 也不会重新计算,而是直接返回上一次缓存的结果。
这个“缓存”机制是计算属性和方法最本质的区别。你可以把计算属性理解为“带备忘录的函数”:同样的输入,如果依赖的数据没变,直接返回记下的答案,不重新算。
3.2 依赖收集的原理:Vue 是怎么知道该不该重算的
很多人会用计算属性,但不太清楚 Vue 是怎么判断“依赖变了”的。这里我尽量讲得通俗一点。
Vue 在初始化 data 中的响应式数据时,会给对象的每个属性定义一个 getter 和 setter,这个机制在 Vue 2 中通过Object.defineProperty实现,在 Vue 3 中通过 Proxy 实现。当计算属性的函数第一次执行时,它会访问那些响应式属性,触发这些属性的 getter。Vue 会把当前计算属性的“订阅者”(watcher)和这个属性关联起来,放到一个依赖集合里。
之后某个地方修改了这个属性,触发 setter,Vue 就知道“哦,这个属性变了”,然后把所有依赖它的 watcher 都标记为 dirty,也就是“缓存失效”。当下一次访问这个计算属性时,它就会重新计算一次,否则就直接用缓存。
这个过程在源码里叫“依赖收集”和“派发更新”。你不需要完全理解源码,但理解了这个机制,你就能解释很多现象:
- 为什么计算属性依赖的 data 没变,但页面上的方法调用了很多次而计算属性没有重复执行?因为方法没有依赖追踪机制,每次渲染都会执行;而计算属性有。
- 为什么在计算属性里访问
Math.random()或者Date.now(),即使在模板中多次使用,计算结果也是一样的?因为这些不是响应式数据,Vue 的依赖系统捕捉不到它们的变化,计算属性也就不会重新计算。 - 为什么在计算属性中修改依赖数据会导致循环依赖的警告?因为计算属性在计算过程中又触发了对依赖数据的订阅,形成套娃。
特别强调一下最后一点,很多人在计算属性里做数据转换时,顺手推入了另一个响应式数据,就可能会导致“Maximized update depth exceeded”之类的报错,或者是无限循环。最简单的规避方法是:计算属性只做“读”和“计算”,不要做“写”。
3.3 computed 的 setter:不只会读,还能“反向改值”
有些文章只讲了计算属性的 getter,忽略了 setter。实际上计算属性也能设置 setter,这在某些双向联动场景中很有用。典型例子是“全选/取消全选”的复选框。
<input type="checkbox" v-model="allChecked" /> computed: { allChecked: { get() { return this.items.every(item => item.checked) }, set(value) { // value 是用户在界面上设置的新值 this.items.forEach(item => { item.checked = value }) } } }这个全选复选框的逻辑就很大方:get 返回“是否所有项都被选中”,set 负责把所有项设置为新的选中状态。当用户点击全选或取消全选时,Vue 会执行 setter,把每一项的 checked 全部更新,界面和状态保持一致。
还有人用 setter 做“值包装”:
<input v-model="formattedName" /> computed: { formattedName: { get() { return this.name.toUpperCase() }, set(value) { this.name = value.trim().toLowerCase() } } }这个场景在接收用户输入时比较有用:界面上显示大写格式,内部存储小写格式。不过要提醒一下,这种写法会让 v-model 的语义变得复杂,如果项目里有多个开发者在维护这个组件,看到这样一个计算属性可能会困惑。我个人的建议是,setter 用在“全选”“范围联动”这类场景比较合适,做输入值格式化还是要谨慎,优先使用 watch 或 change 事件。
3.4 computed 是同步的,别放异步逻辑
还有一条重要限制:计算属性必须是同步、纯计算的,不能在里面发请求或者做异步操作。因为计算属性的返回值是同步被模板读取的,如果函数内部有 setTimeout、Promise 等异步逻辑,返回值根本等不到异步操作完成。
computed: { // 错误示例:永远拿不到异步结果 asyncData() { setTimeout(() => { return this.value + 1 }, 1000) } }这段代码里,asyncData 的返回值是 setTimeout 标志ID,而不是异步计算后的结果。有人可能在模板里看到undefined或者一个数字,然后排查半天。如果你确实要根据异步数据来渲染,应该使用 data + watch 或者使用 Vuex 的状态管理,而不是把它放在 computed 里。
4. computed vs methods vs watch:三兄弟怎么选
4.1 核心差异对比
我在带团队的时候,经常有同事问我:“computed 和 methods 不是差不多吗?为什么非要用 computed?”我先用一个对比表说清楚它们的定位:
| 对比维度 | computed | methods | watch |
|---|---|---|---|
| 是否缓存结果 | 依赖数据不变就缓存,不重算 | 每次渲染都重新执行 | 不关注返回值,数据变化时执行回调 |
| 用途 | 根据已有数据计算并展示新值 | 事件处理、触发某些动作 | 监听一个数据变化,执行副作用操作 |
| 在模板中使用 | 直接写名字,无需括号 | 需要加括号调用 | 一般不在模板中使用 |
| 是否可以异步 | 不可以 | 可以 | 可以 |
| 是否触发额外渲染 | 不会直接引起渲染 | 不主动引起渲染 | 会在目标数据变化时被触发,回调里可以做任何事 |
从模板中的使用方式来说,computed 是“值”,methods 是“动作”。一旦一个计算逻辑依赖多个变量、且结果会被多次引用,就适合 computed。如果是一个按钮点击后的处理流程,适合 methods。如果某个数据变化时你需要同步调用接口、重置其他数据、或者做一些非展示类操作,适合 watch。
4.2 什么时候 watch 更合适
一个典型的场景:搜索框的远程搜索。用户输入关键词,你不希望每次敲一个字母就发请求,而是希望停止输入 300ms 后再发,这时 watch 就比较合适,配合debounce或者setTimeout。
watch: { keyword(newValue, oldValue) { if (this.timer) clearTimeout(this.timer) this.timer = setTimeout(() => { this.search(newValue) }, 300) } }这里使用 watch,是因为“搜索”这个动作是对数据变化的一种副作用响应,它不是用于同步计算一个显示值,而是触发一系列外部操作(发请求、更新列表状态)。如果你把这个逻辑放在 computed 里,无法执行异步请求。
再举一个 watch 的使用场景:多级联动选择器。省市区联动中,当省份变化时,需要重置城市和区的选择,并且重新加载城市数据。这种“一个变化引起多个状态调整”的连锁反应,用 watch 很自然。
watch: { provinceId(newValue) { this.cityId = '' this.districtId = '' this.cityList = [] this.fetchCityList(newValue) } }但要注意:watch 的 deep 模式监听对象时,开销挺大的,不要滥用。监听一个嵌套很深的对象,性能会非常差。优先考虑拆分成多个简单字段,或者使用 computed 生成需要监听的字段。
4.3 我的一些选型心得
基于多年项目经验,我总结了一套比较简单粗暴的判断规则:
- 模板中用到的是“值”,且这个值是依赖多个数据计算得出的,选 computed。
- 模板中用到的是一个操作、一次交互后的流程处理,选 methods。
- 某个数据变化后,你需要做“额外的事情”,比如调接口、存储、触发其他组件的方法,选 watch。
- 如果一个计算逻辑在模板中被使用了多次,且运算量不低,优先用 computed,这不仅是性能考虑,代码可读性也更高。
还有一种情况特别值得警惕:有人喜欢把 methods 里的函数拿来当值用,比如{{ formatPrice(item.price) }}。如果这个 formatPrice 还会被循环中每个 item 反复调用,并且格式化逻辑较重,每次渲染都会重新执行,页面性能下降得很快。改用 computed 返回一个格式化后的数组,或者对每项做缓存处理,会稳妥很多。
5. 实战场景驱动:事件对象+计算属性的组合实践
5.1 搜索框联动:防抖 + 状态计算
搜索框是前后端项目中几乎必有的功能。我们来写一个比较完整的例子:用户输入关键词,前端实时计算可展示的搜索结果数量,并且在停止输入 500ms 后执行搜索请求。
<template> <div> <input type="text" v-model="keyword" @input="handleInput" @keyup.enter="handleSearch" placeholder="请输入关键词搜索" /> <p>当前输入长度:{{ inputLength }}</p> <p>可用搜索词:{{ validKeyword }}</p> <p>匹配数量:{{ matchCount }}</p> </div> </template> <script> export default { data() { return { keyword: '', list: ['苹果', '香蕉', '橘子', '西瓜', '梨'] } }, computed: { inputLength() { return this.keyword.length }, validKeyword() { return this.keyword.trim() }, matchCount() { const kw = this.validKeyword.toLowerCase() if (!kw) return 0 return this.list.filter(item => item.toLowerCase().includes(kw)).length } }, methods: { handleInput() { if (this.debounceTimer) clearTimeout(this.debounceTimer) this.debounceTimer = setTimeout(() => { console.log('开始搜索:', this.validKeyword) }, 500) }, handleSearch() { if (this.debounceTimer) clearTimeout(this.debounceTimer) console.log('立即搜索:', this.validKeyword) } } } </script>这个例子里,输入长度、有效搜索词、匹配数量都是根据 keyword 派生出来的展示值,用 computed 合理。搜索动作属于“用户输入后的异步操作”,用 methods + 防抖合理。所有逻辑分层清晰,一眼就能看懂。
5.2 购物车结算面板:总计、折扣、运费的计算
购物车是计算属性的“主场”。我写一个稍微复杂的版本,包含满减、运费、折扣价等逻辑。
<template> <div class="cart-panel"> <div v-for="item in cart" :key="item.id" class="cart-row"> <input type="checkbox" v-model="item.checked" /> <span>{{ item.name }}</span> <span>单价:¥{{ item.price }}</span> <span>数量:{{ item.quantity }}</span> <span>小计:¥{{ getSubtotal(item) }}</span> </div> <div> <p>已选商品数:{{ selectedCount }}</p> <p>商品原价总计:¥{{ originalTotal }}</p> <p>满减优惠:-¥{{ discountAmount }}</p> <p>运费:¥{{ shippingFee }}</p> <p>应付总计:¥{{ finalTotal }}</p> </div> </div> </template> <script> export default { data() { return { cart: [ { id: 1, name: '笔记本', price: 2999, quantity: 1, checked: false }, { id: 2, name: '鼠标', price: 129, quantity: 2, checked: true }, { id: 3, name: '键盘', price: 399, quantity: 1, checked: false } ] } }, computed: { selectedCart() { return this.cart.filter(item => item.checked) }, selectedCount() { return this.selectedCart.reduce((sum, item) => sum + item.quantity, 0) }, originalTotal() { return this.selectedCart.reduce((sum, item) => sum + item.price * item.quantity, 0) }, discountAmount() { // 满2000减100 if (this.originalTotal >= 2000) { return Math.floor(this.originalTotal / 2000) * 100 } return 0 }, shippingFee() { // 满500包邮,否则收15元 if (this.originalTotal === 0 || this.originalTotal >= 500) { return 0 } return 15 }, finalTotal() { const total = this.originalTotal - this.discountAmount + this.shippingFee return Math.max(0, total) } }, methods: { getSubtotal(item) { return item.price * item.quantity } } } </script>有个细节,我故意用了getSubtotal(item)这个方法而不是写成一个计算属性。因为小计是依赖 item 参数的计算,它只能在循环中使用,无法通过一个 computed 直接表达“对每个 item 求小计”。当然你也可以改成 computed 返回一个带小计的新数组,这种方式更适合复杂的展示需求。这里保留方法调用,是为了展示“方法在循环中的使用场景”。
在计算 finalTotal 时,我依赖了 discountAmount 和 shippingFee,它们又分别依赖 originalTotal,而 originalTotal 又依赖 selectedCart。这就是计算属性的链式依赖。Vue 的依赖收集机制允许这种嵌套依赖,它会自动维护依赖层级,某个上游数据发生变化时,下游所有依赖它的计算属性都会一起重新计算。
5.3 表单状态联动:是否可提交、剩余字符数
表单场景中,计算属性和事件对象同样经常配合使用。比如一个发布文章的简单表单,提交按钮是否可点击,取决于标题非空、内容长度不超限,以及“用户是否曾尝试提交过”。
<template> <div> <form @submit.prevent="handleSubmit"> <div> <label>标题</label> <input v-model="title" @input="handleTouch('title')" /> <span v-if="touchList.title && !title">标题不能为空</span> </div> <div> <label>内容</label> <textarea v-model="content" maxlength="500" @input="handleTouch('content')"></textarea> <span>剩余 {{ remainingCount }} 字</span> </div> <button type="submit" :disabled="!canSubmit">发布</button> </form> </div> </template> <script> export default { data() { return { title: '', content: '', touchList: { title: false, content: false } } }, computed: { remainingCount() { return 500 - this.content.length }, canSubmit() { // 标题非空、内容非空、内容长度不超过500 return this.title.trim() !== '' && this.content.trim() !== '' && this.content.length <= 500 } }, methods: { handleTouch(field) { // 标记用户操作过该字段,用于显示错误信息 this.touchList[field] = true }, handleSubmit() { if (!this.canSubmit) return console.log('提交成功', this.title, this.content) } } } </script>这里的@input="handleTouch('title')"就是事件对象参与业务逻辑的典型场景:你并不关心事件对象的细节,只是用它来给这个输入框做“已触碰”标记。当然,原生的 input 事件会作为一个参数传进来,但当你不写$event时,Vue 会把原生事件对象作为第一个参数传入,但 handleTouch 里第一个参数是 'title',并不会导致问题,因为 handleTouch 内部并没有用到事件对象。这里要注意,如果确实需要同时传参和事件对象,要显式写成@input="handleTouch('title', $event)"。
5.4 常见问题与排查技巧实录
最后,我把实际开发中遇到的和事件对象、计算属性相关的典型问题整理成一张速查表,方便大家排查:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 点击子元素,父元素也触发了事件 | 事件冒泡 | 在子元素上添加.stop修饰符 |
| 点击按钮导致页面刷新 | 按钮在 form 内部,触发了原生提交行为 | 给按钮添加type="button",或给 form 添加@submit.prevent |
| computed 的值永不更新 | 依赖了非响应式数据 | 确保依赖项在 data / props / 其他 computed 中声明 |
| computed 计算结果在模板中为 undefined | 计算属性中的函数写成了异步逻辑 | 把异步逻辑移到 watch 或 methods 中,computed 仅做同步计算 |
| computed 内修改依赖数据,报“递归更新”警告 | 计算属性内部写入了响应式数据 | 计算属性只读不写,把写操作放在 watch 或事件处理中 |
| 列表量非常大时页面卡顿 | 每个子项单独绑定大量监听器,或者模板中调用大量方法 | 使用事件委托,将监听器绑定到父元素;把高频展示数据用 computed 缓存 |
| watch 监听复杂对象,频繁触发 | 使用了 deep: true,内部任意字段变化都会触发回调 | 尽量减少 deep 的使用,或改监听对象的指定字段 |
| 搜索框输入时频繁发请求 | 没有做防抖 | 在 input 事件或 watch 回调中增加 setTimeout + clearTimeout 防抖逻辑 |
再分享一个我印象很深的坑:某次在项目中,有一段代码在 computed 里返回一个未声明的变量,Vue 不会报错,模板里显示 undefined,我排查了很久才发现是在 computed 内部引用了一个不属于组件的全局变量。结果就是:这个计算属性根本不依赖任何响应式数据,页面其他数据更新时,它永远显示的是第一次缓存的结果。所以,如果 computed 返回的结果长时间不更新,首先检查它依赖的数据是否真的是响应式数据。
另一个常见问题是在模板中写复杂表达式。有些同学喜欢在模板直接处理字符串、数组截断等逻辑:
<!-- 不推荐 --> <span>{{ fullName.split(' ').map(n => n.charAt(0).toUpperCase()).join(' ') }}</span>这种写法不是不能用,但可读性很差,而且每次渲染都会执行。建议把这部分逻辑提取到 computed 中,既清晰又性能友好。
还有一个容易踩的性能坑:computed 中依赖的响应式数据如果是一个大数组,并且你在这个 computed 里做了filter、map、reduce等遍历操作,当数组变化时,这个 computed 会重新执行整段计算。如果这个计算还被多个地方引用,可能会重复计算。解决办法是把中间结果拆成多个 computed,形成依赖链,让每个 computed 只负责一层计算。比如:
computed: { // 第一步:过滤 validList() { return this.allList.filter(item => item.status === 'active') }, // 第二步:排序 sortedList() { return [...this.validList].sort((a, b) => a.sort - b.sort) }, // 第三步:统计 totalAmount() { return this.sortedList.reduce((sum, item) => sum + item.amount, 0) } }这样有一个好处:如果 allList 变化了,三个 computed 都会重新计算;但如果只有排序条件变了,第一步的 validList 可以命中缓存,不需要再次 filter。虽然这个例子中排序条件变化不频繁,但把复杂计算拆分成多条依赖链,调试时会方便很多,性能也有保障。
关于事件对象,我再补一个小技巧。移动端开发时,滚动事件触发非常频繁,如果在 scroll 事件回调中做大量 DOM 操作或者复杂计算,会明显卡顿。你可以利用.passive修饰符来标记这个事件不需要调用 preventDefault,从而让浏览器在事件处理时不做额外的判断,直接走默认行为,滚动性能会有所提升。给滚动容器的@scroll.passive="onScroll"就是这种用法。要注意,.passive和.prevent不能同时使用,因为 passive 本身就告诉浏览器“我不会阻止默认行为”,如果同时声明 prevent,浏览器会直接忽略 prevent。
最后说一下 Vue Devtools 调试计算属性的小技巧。如果你使用 Vue Devtools,可以在组件面板里看到每个 computed 属性的值,并且能看到它的依赖项。当一个 computed 的值看起来不对时,先在 Devtools 里看它依赖的 data 的实际值,再决定是依赖收集出了问题还是计算逻辑出了问题。这个排查路径比在模板中加console.log高效得多。
根据我个人经验,事件对象和计算属性这两个东西,在你用 Vue 的初期就能接触到,但它们之间的配合和取舍,往往要经历了几个复杂项目才能真正吃透。事件对象的关键在于理解事件传播机制,计算属性的关键在理解响应式缓存,把这两块打牢了,很多“莫名其妙”的 bug 都能一眼看穿。这篇文章里的例子,都是在实际项目中反复验证过的写法,你完全可以照着敲一遍,再根据自己的业务场景调整。