Code前端首页关于Code前端联系我们

Vue3 watch的flush选项选sync、pre还是post?别再凭感觉试了

terry 57分钟前 阅读数 13 #Vue
文章标签 Vue3 watchflush选项

做Vue3开发快3年了,我见过太多开发者遇到watch触发时机的坑:要么想在DOM更新前拿到旧值加个过渡动画,结果选了post白等半天;要么刚改完状态就要获取新DOM的尺寸,用pre/sync拿到的还是缩成一团的旧布局;更离谱的是有时候用sync修改响应式数据,导致后续逻辑串了顺序,debug半天查不到根源,这些问题说到底,都是没搞懂watch的flush三个值到底在管什么,什么时候该用哪一个,今天我就把踩过的坑、翻文档啃源码加实际项目总结的经验,完完整整讲清楚。

先回顾Vue3 watch的触发机制核心逻辑,搞懂flush的存在意义

很多人可能上来就记“pre是默认,DOM前;post是DOM后;sync是同步”,但一到复杂场景(比如同时有多个watch、watch里修改依赖的同状态/其他状态、结合nextTick)就懵,这是因为没搞懂Vue的响应式更新和DOM渲染是怎么配合的。

Vue3用Proxy做响应式拦截,当你修改某个响应式数据时,它不会立刻触发watch回调、不会立刻更新DOM,而是先把这次修改“攒”起来——用的是微任务队列,对吧?攒的目的是什么?避免重复计算:比如你连续修改count三次,只需要让count对应的组件重新渲染一次,count相关的watch回调如果不设immediate/flush: sync,也只会触发最后一次值变化的逻辑。

那攒到什么时候执行呢?首先要触发所有标记为pre的watch回调(默认就是pre),这些回调是在组件更新前(也就是虚拟DOM diff前、真实DOM修改前) 跑的;然后Vue开始做虚拟DOM对比、生成真实DOM补丁、更新页面;DOM更新完之后,再触发所有post的watch回调。

而sync不一样,它根本不进这个“攒起来的微任务循环链”,一旦响应式数据发生变化,拦截器里立刻就触发sync的watch回调,不管现在有没有其他数据在攒,不管组件要不要更新。

这么说有点干,我们可以用一个简单的日常例子类比:

  • 响应式数据是外卖订单,一次点好几个菜相当于连续改好几个数据;
  • 外卖平台的后台(Vue的响应式系统)不会每加一个菜就给骑手派一次单,而是先等你加完所有菜、点击提交(触发当前同步代码执行完毕的微任务检查点);
  • 骑手出发前(pre阶段),可以先检查旧菜单有没有备注漏加(拿到旧状态做收尾);
  • 骑手把菜送到你家、摆好桌子(post阶段,DOM更新完),你才能拿到新菜拍照发朋友圈(获取新DOM);
  • 只有那种你加了菜立刻后悔,或者有个菜必须立刻决定要不要换的情况(同步操作需求),后台才会立刻给你发确认消息(触发sync的watch)。

逐个拆解flush的三个值,附真实项目场景说明

现在我们把类比落地到代码,每个flush值配至少两个不同的真实开发场景,帮你彻底记住什么时候用。

第一个值:pre(默认值,开发中用得最多但要注意边界)

pre watch的官方定义是“在组件更新前触发”,再准确点是:

  1. 在当前同步代码块结束后的第一个微任务里,优先于组件虚拟DOM diff执行
  2. 回调里拿到的oldValue是上一次更新后的旧值;
  3. 此时响应式数据的最新值已经存在了,但真实DOM还没动
  4. 如果在pre watch里修改了依赖的同状态或其他状态,这些修改会继续被拦截、攒进同一个微任务,组件还是只会更新一次,但要注意会不会导致逻辑死循环(比如A改了pre watch里A++,就会一直触发)。

场景1:DOM更新前做旧数据的收尾/状态的二次同步

上周我做一个日历组件,用户切换月份时,需要先把当前选中的日期(selectedDate)重置为新月份的1号,同时给旧的选中日期加个淡出的动画过渡到新的。 这里就有个问题:如果直接把selectedDate的切换放在按钮点击事件里,再给DOM加transition,可能淡出动画还没播完新日期就已经显示选中状态了(因为DOM更新和状态更新的配合有时候差那么一点点,但用pre就更稳)。 后来我改成了:把切换月份的逻辑单独放在month的ref里,点击按钮只改month;然后写一个watch month的pre回调,在里面先记录oldMonth,给旧月份的DOM加个临时的淡出类(因为此时DOM还是旧的,类名能生效),再同步selectedDate为新月份的1号,最后等nextTick(nextTick是在DOM更新后的第一个微任务,比pre watch晚但比post watch同时期吗?哦不对,等下后面要讲nextTick和flush的顺序)把临时类名去掉。

场景2:提前计算好更新时需要用到的辅助数据

比如我做过一个柱状图组件,当用户修改统计的时间跨度(span)时,需要重新计算每个柱子的宽度(width),width会作为props传给子组件BarItem,子组件又根据width来做一些内部的布局调整。 一开始我把width的计算放在computed里,没问题,但后来发现BarItem的内部布局需要基于父组件的宽度(chartWidth)来再微调,chartWidth是用ref存的,通过ResizeObserver监听父元素获取的。 如果继续用computed,当chartWidth和span同时变化时,computed会触发两次?或者有时候顺序不对?哦其实computed是惰性的,只有当渲染时才会重新计算,但有时候我们需要确保在BarItem渲染之前,width已经是结合了最新span和最新chartWidth的最终值——这时候pre watch就派上用场了:把chartWidth和span同时作为watch的依赖,设置flush: pre,在回调里计算width并赋值给ref,这样BarItem渲染时拿到的就是最新的最终值,不会有二次渲染的问题(computed其实也能解决,但如果计算逻辑很复杂,而且有副作用?不对watch才有副作用,但这里的计算width其实是纯的,那为什么还要用?哦比如计算之后需要提前缓存一些和DOM相关的属性(虽然此时DOM还没动,但可以根据旧DOM和新数据预测),这时候pre就更合适了)。

第二个值:post(专门处理需要最新DOM的场景,别再乱用nextTick了)

很多开发者遇到“获取最新DOM尺寸/位置/内容”的问题,第一反应就是用nextTick,但其实用watch的post选项更直接、更语义化,而且在某些复杂场景下(比如多个watch修改多个数据,nextTick可能对应不上正确的那次修改)更可靠。

post watch的官方定义是“在组件更新后触发”,再准确点是:

  1. 在当前同步代码块结束后的微任务循环里,排在组件虚拟DOM diff、真实DOM更新之后执行
  2. 回调里拿到的oldValue还是上一次更新后的旧值;
  3. 此时响应式数据的最新值和真实DOM的最新状态都已经存在了
  4. 如果在post watch里修改了依赖的同状态或其他状态,这些修改会触发新一轮的微任务循环(因为上一轮已经结束了),组件会再更新一次,这时候要注意会不会导致频繁更新(比如B改了post watch里的B,就会触发新一轮的pre watch、DOM更新、post watch,循环下去,除非有条件判断)。

场景1:修改数据后立刻获取新DOM的尺寸

这应该是post watch最常用的场景了,比如我做过一个可折叠的侧边栏组件,用户点击折叠按钮时,会修改isCollapse的ref,然后侧边栏的宽度会从240px变到64px(或者相反),这时候我需要根据新的侧边栏宽度来调整主内容区的margin-left,同时更新本地存储里的侧边栏宽度值(不过本地存储其实pre也能做,因为不需要DOM),区的margin-left其实也可以用computed,但如果主内容区里有个canvas图表,需要在侧边栏折叠/展开后,立刻调用chart.resize()来适配新的宽度——这时候computed就做不到了,因为chart.resize()是副作用,而且必须等主内容区的DOM宽度真的变了之后才能调用,不然resize的还是旧宽度。 一开始我用的是“修改isCollapse → 调用nextTick(chart.resize)”,但后来发现如果同时有其他watch修改了主内容区的其他DOM属性(比如padding),导致主内容区更新了两次,那nextTick可能第一次就触发了chart.resize,那时候padding还没改,第二次主内容区更新完chart又没resize——这时候换成watch isCollapse的post回调,在里面先判断isCollapse有没有真的变(虽然Vue3 watch默认是浅比较,但可以用deep或者第三个参数的配置),再等一下?不不用等,post已经是DOM更新完了,直接调用chart.resize(),而且这个watch只会对应isCollapse的变化,不会被其他数据的更新干扰,完美。

场景2:结合编辑器的内容同步

比如我做过一个支持Markdown实时预览的组件,左边是textarea编辑器(用ref绑定了markdownContent),右边是预览区的div(用v-html绑定了previewContent)。 一开始我把previewContent的生成放在computed里,没问题,但后来预览区要支持语法高亮(用的是highlight.js),highlight.js需要在previewContent的DOM插入之后,遍历里面的code标签添加样式类——这时候computed就做不到了,因为computed只负责生成字符串,不管DOM有没有渲染。 一开始我用的是watch markdownContent的pre回调,生成previewContent,然后调用nextTick(highlightAllCode),但后来发现如果markdownContent的内容特别长,computed或者pre回调生成previewContent需要一点时间,而且Vue的虚拟DOM diff和渲染也需要一点时间,有时候nextTick触发时highlightAllCode遍历到的还是旧的code标签——换成post watch就解决了:把生成previewContent也放在post watch里?哦不对生成previewContent其实不需要DOM,应该放在pre或者computed里,然后在post watch里专门调用highlightAllCode,这样更清晰,而且绝对能拿到最新的DOM。

第三个值:sync(慎用!只有非常特殊的同步场景才需要)

sync watch的官方定义是“同步触发”,再准确点是:

  1. 一旦响应式数据被Proxy拦截到变化,不管现在有没有其他代码在执行,立刻触发回调
  2. 回调里拿到的oldValue是变化前的瞬时旧值
  3. 此时响应式数据的最新值已经存在,但真实DOM肯定还没动
  4. 如果在sync watch里修改了依赖的同状态或其他状态,这些修改会立刻触发其他依赖该状态的sync watch,然后再继续执行原来的代码,这时候要非常注意逻辑顺序,很容易导致逻辑混乱或者死循环;
  5. sync watch的性能最差,因为它会打断Vue的“攒修改”机制,增加不必要的计算和可能的DOM更新?不对sync watch不会直接触发DOM更新,只是会触发自己的回调,但如果在回调里修改了很多数据,还是会攒到同步代码块结束后的微任务里更新DOM。

场景1:同步验证表单输入的合法性,并且要阻止默认行为

哦等下表单输入的默认行为比如keydown.enter提交表单,能不能用sync watch?其实可以,但用keydown事件的preventDefault更直接?不对比如我做过一个实时搜索的输入框,用户输入时不能输入特殊字符(@#$%^&*()),而且每次输入后要立刻过滤掉特殊字符,更新输入框的value,还要触发搜索请求? 哦过滤特殊字符其实可以用input事件的处理函数,但如果输入框是双向绑定的v-model="searchText",那用户输入的内容会先更新searchText,然后触发input事件,再过滤searchText,再更新——这时候如果用普通的watch(pre),过滤后的searchText会在同一个微任务里,但如果用户输入特别快,会不会有一瞬间输入框里显示特殊字符?其实不会,因为Vue的更新很快,但用sync watch可以更“即时”:当searchText被修改(用户输入特殊字符触发v-model的input事件更新searchText)时,sync watch立刻过滤,把searchText改回没有特殊字符的版本,而且因为是同步,所以用户输入框里根本不会显示特殊字符(除非浏览器的事件处理和Vue的响应式拦截有什么顺序问题?但我实际测试过,是可以的)。

场景2:多个响应式数据之间需要严格同步,不能有时间差

比如我做过一个双重滑块组件,有两个滑块:minPrice和maxPrice,要求minPrice必须小于等于maxPrice,maxPrice必须大于等于minPrice,而且两个滑块的拖动不能有“跳跃感”——什么叫跳跃感?比如你拖动maxPrice的滑块到100,但此时minPrice是80,没问题;但如果你继续拖动maxPrice的滑块到70,这时候如果用普通的watch(pre),可能会先触发maxPrice的watch,把minPrice改成70,然后再更新DOM,这时候maxPrice的滑块可能会“停”在70的位置,但如果拖动速度特别快,会不会有一瞬间用户看到maxPrice的滑块到了60,然后minPrice和maxPrice才一起跳到70?其实不会,但用sync watch可以更严格:当maxPrice被修改时,sync watch立刻检查,如果maxPrice < minPrice,就把maxPrice设置为minPrice;当minPrice被修改时,sync watch立刻检查,如果minPrice > maxPrice,就把minPrice设置为maxPrice,这样两个滑块的位置永远是合法的,没有任何时间差。

最容易混淆的点:flush: post和nextTick到底有什么区别?

刚才讲post的时候提到过,很多人分不清什么时候用post什么时候用nextTick,现在我就从触发时机、触发条件、语义化三个方面把它们讲清楚。

触发时机

首先我们要把Vue的更新流程(结合事件循环)完整走一遍,这样所有的时机问题都迎刃而解:

  1. 同步代码执行阶段:比如你在按钮点击事件里修改了count、name两个响应式数据,这两个修改都会被Proxy拦截,标记为“脏数据”,count的sync watch立刻触发(如果有的话),name的sync watch也立刻触发(如果有的话);
  2. 微任务检查点:同步代码执行完毕后,JavaScript引擎会检查微任务队列,Vue会把pre watch、组件虚拟DOM diff、真实DOM更新、post watch、用户手动调用的nextTick回调,按顺序放进微任务队列(注意不是同步放进去,是分批处理?不对更准确的是,Vue有自己的内部调度队列,调度队列里的job会被包装成微任务,然后按优先级执行:pre watch的优先级最高,然后是组件更新的job,然后是post watch的job,然后是用户手动调用的nextTick的job);
  3. 宏任务阶段:微任务队列清空后,才会执行宏任务队列(比如setTimeout、setInterval、DOM事件的后续处理?不对DOM事件本身是宏任务触发的,但事件处理函数是同步执行的)。

举个具体的代码例子:

import { ref, watch, nextTick } from 'vue'
const count = ref(0)
// 手动调用nextTick1
nextTick(() => {
  console.log('nextTick1:', count.value)
})
// 修改count
count.value = 1
// pre watch(默认)
watch(count, (newVal, oldVal) => {
  console.log('pre watch:', newVal, oldVal)
  // 手动调用nextTick2
  nextTick(() => {
    console.log('nextTick2:', count.value)
  })
})
// post watch
watch(count, (newVal, oldVal) => {
  console.log('post watch:', newVal, oldVal)
}, { flush: 'post' })
// 手动调用nextTick3
nextTick(() => {
  console.log('nextTick3:', count.value)
})
// 同步输出
console.log('同步代码结束:', count.value)

你猜这个代码的输出顺序是什么? 先公布答案:

  1. 同步代码结束: 1
  2. pre watch: 1 0
  3. post watch: 1 0
  4. nextTick1: 1
  5. nextTick3: 1
  6. nextTick2: 1

哦对了这里要注意:

  • nextTick1是在count修改之前调用的,但它还是会在count修改后的微任务循环里执行,因为count修改后的调度队列是在同一个微任务循环里处理的;
  • pre watch里调用的nextTick2,是在当前调度队列的所有job(pre、组件更新、post)执行完之后,下一个微任务循环里执行的;
  • post watch比nextTick1和nextTick3先执行,因为它在Vue内部调度队列的优先级比用户手动调用的nextTick高。

触发条件

  • flush: post watch:只有当它依赖的响应式数据发生变化时才会触发,而且每次变化(浅比较/深比较通过的变化)都会触发一次;
  • nextTick:不管有没有响应式数据变化,只要你调用了它,它就会在DOM更新后的第一个微任务里执行(如果当前没有DOM更新的任务,它就会在当前同步代码块结束后的第一个微任务里执行)。

语义化

  • flush: post watch:语义化非常强,它明确表示“我要在这个数据变化,并且DOM更新之后,做某件事”;
  • nextTick:语义化比较弱,它只是表示“我要在DOM更新之后做某件事”,但不知道是哪次数据变化导致的DOM更新。

所以总结一下:

  • 如果你要做的事明确和某个(或某几个)响应式数据的变化绑定,而且需要最新的DOM,那就用flush: post watch;
  • 如果你要做的事和任何响应式数据的变化都没有直接绑定,只是想在当前所有同步代码执行完、DOM更新完之后做某件事,那就用nextTick。

实战避坑指南:这5个flush的错误用法我踩过至少3个

错误用法1:在pre watch里获取最新DOM尺寸

这个是最常见的错误,刚才讲post的时候已经举过例子了,pre watch时真实DOM还没动,拿到的肯定是旧尺寸,别再犯了。

错误用法2:在sync watch里修改多个依赖的同状态,导致死循环

比如刚才讲的双重滑块组件,如果你在sync watch里没有加条件判断,

watch(minPrice, (newVal) => {
  // 错误!没有加条件判断,不管newVal有没有超过maxPrice,都会修改minPrice和maxPrice?不对下面的代码才会死循环
  maxPrice.value = newVal + 10
}, { flush: 'sync' })
watch(maxPrice, (newVal) => {
  minPrice.value = newVal - 10
}, { flush: 'sync' })

这段代码一执行就会死循环:修改minPrice → 触发minPrice的sync watch → 修改maxPrice → 触发maxPrice的sync watch → 修改minPrice → 无限循环下去。 所以在sync watch里修改依赖的同状态或其他状态时,一定要加严格的条件判断,确保不会无限触发。

错误用法3:同时用pre watch和computed做同一个纯计算逻辑

computed的性能比pre watch好,因为computed是惰性的,只有当渲染时或者被其他computed/watch依赖时才会重新计算,而pre watch只要依赖的响应式数据变化就会立刻触发,不管有没有用到。 所以如果是纯计算逻辑(没有副作用),优先用computed,别用pre watch。

错误用法4:在post watch里做太多耗时的同步操作

post watch是在DOM更新后、微任务队列里执行的,如果在里面做太多耗时的同步操作(比如遍历一个几万条数据的数组),会阻塞后续的微任务和宏任务,导致页面卡顿(比如用户的下一个点击事件要等很久才能响应)。 所以如果有耗时的操作,应该放在post watch里的setTimeout里(改成宏任务),或者用Web Worker处理。

错误用法5:滥用flush: deep和flush: sync

deep watch会深度遍历响应式对象的所有属性,性能很差;sync watch会打断Vue的“攒修改”机制,可能导致逻辑混乱。 所以只有当你真的需要深度监听对象的属性变化时,才用flush: deep;只有当你真的需要同步触发回调时,才用flush: sync。

一张表格帮你快速决定选哪个flush

flush值 触发时机 适用场景 注意事项
pre(默认) 组件更新前(虚拟DOM diff前),当前同步代码结束后的第一个微任务里,优先级最高 旧数据收尾、状态二次同步、提前计算辅助数据 别获取最新DOM,修改状态要注意死循环
post 组件更新后(真实DOM修改后),当前同步代码结束后的微任务循环里,排在组件更新之后、用户手动调用的nextTick之前 获取最新DOM尺寸/位置/内容、结合编辑器的内容同步、处理有DOM依赖的副作用 修改状态会触发新一轮更新,别做太多耗时同步操作
sync 响应式数据变化时立刻同步触发 同步验证输入合法性、多个数据严格同步 性能最差,修改状态要加严格条件判断,避免死循环和逻辑混乱

好啦,今天关于Vue3 watch flush的内容就讲完了,希望能帮到你,以后别再凭感觉试flush选项了,根据场景选就对了,如果还有什么问题,欢迎在评论区留言讨论。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门