效果预览

API设计
需求
tabs的子组件只能是tab组件
点击标签切换,动态渲染内容 – v-model:selected,动态组件
tab标签下横线移动,且长度根据标签宽度变化
用户怎么用该组件
1 | <x-tabs v-model:selected="selectTab"> |
实现过程
从需求出发,将每个需求分别拆分成几个小问题一一解决
如何确认子组件的类型
要解决这个问题,首先得知道如何拿到子组件。由于Tab组件都是通过默认插槽的形式和Tabs组件一起使用,因此只要拿到Tabs组件的插槽就可以了。
在Vue2中,是使用$slots来获取插槽。而在Vue3中,通过文档可知,setup函数接受的第二个参数context中,暴露了slots这个属性,因此可通过context.slots后获取插槽,再通过组件的name判断组件类型
1 | const defaults = context.slots.default() |
如何动态渲染嵌套的组件
拿到了默认插槽(tab组件)后,首先想到的是通过循环渲染,然后比较子组件的name属性和selected的值过滤未选中的组件内容。
然而这样会遇到一个问题:将v-for和v-if一起使用了。官方文档并不推荐这样做。
因此,先通过计算属性拿到当前选中的组件,再通过动态组件渲染
1 | <component :is="currentTab" :key="selected" /> |
1 | setup(){ |
注:如果key标识也改变,否则将无法动态变化
如何制作移动的导航条
确定标签导航的html结构
1 | <div class="xx-tabs-nav"> |
根据需求,导航条的宽度应为当前选中标签的宽度,而导航条的位置应该是选中标签的left - 导航容器的left。那么问题就变成了:如何获取导航容器,导航条,选中标签这三个DOM元素呢?
如何获取DOM元素
在Vue2中,可以使用ref和$refs来获取DOM元素,而在Vue3中有了新变化。如果在v-for中使用ref,需要将 ref 绑定到一个更灵活的函数上。
基于此,可以使用如下方式创建设置导航条属性的方法:
1 | <div class="xx-tabs-nav" ref="navContainer"> |
1 | setup(){ |
如何实现宽度动态变化
很容易想到,当控制标签选中的变量selected发生变化后,生命周期函数updated即会执行,因此我们可以在组件挂载完成及更新完成时分别调用getIndicatorAttr方法实现宽度的动态变化。
在Vue 3中,可以使用直接导入的 onX 函数注册生命周期钩子:
1 | import { onMounted, onUpdated } from 'vue' |
如何优化
虽然需求已经实现,但问题又来了:这段代码还有没有优化空间呢?
于是想到,能不能通过监听selected的变化来实现getIndicatorAttr方法的调用呢?
查阅文档发现,Vue3还提供了一个新特性——watchEffect响应式追踪其依赖的变化。于是根据文档进行了尝试:
1 |
|
可以看到watchEffect会在onMounted执行前调用一次,并在所传入函数中的依赖发生变化时再次被调用。
但DOM元素在组件挂载完成后才能获取到,因此采取了这样的调用方式:
1 | onMounted(()=>{ |
然而,当我欣喜地期待着功能完美实现的时候,马上就打脸了:

解决使用watchEffect的bug
这是什么情况?马上打console看了看:
1 | onMounted(()=>{ |

重新梳理逻辑:

原本是期望selected变化导致selectedItem变化,之后watchEffect执行,改变indicator位置。实际上却是:watchEffect在selectedItem变化前就执行了,发现其还未变化,因此传入的函数并未立即调用。
查阅文档发现确实如此:
Vue 的响应性系统会缓存副作用函数,并异步地刷新它们,这样可以避免同一个“tick” 中多个状态改变导致的不必要的重复调用。在核心的具体实现中,组件的
update函数也是一个被侦听的副作用。当一个用户定义的副作用函数进入队列时,默认情况下,会在所有的组件update前执行
文档也给出了相应的解决方案:
如果需要在组件更新后重新运行侦听器副作用,可以传递带有
flush选项的附加options对象 (默认为'pre'):
1 | // fire before component updates |

Vue3笔记
ref
在 Vue 2 中,在 v-for 里使用的 ref 会用 ref 数组填充相应的 $refs property。当存在嵌套的 v-for 时,这种行为会变得不明确且效率低下。
在 Vue 3 中,这样的用法将不再在 $ref 中自动创建数组。如果要从单个绑定获取多个 ref,需将 ref 绑定到一个更灵活的函数上
1 | <div v-for="item in list" :ref="setItemRef"></div> |
1 | import { ref, onBeforeUpdate, onUpdated } from 'vue' |
watch
watch 需要侦听特定的数据源,并在回调函数中执行副作用。默认情况下,它也是惰性的,即只有当被侦听的源发生变化时才执行回调
侦听单个数据源
1 | // 侦听一个 getter |
侦听多个数据源
1 | watch([fooRef, barRef], ([foo, bar], [prevFoo, prevBar]) => { |
watchEffect
watchEffect方法接收的第一个参数:effect函数,用于定义副作用。他会立即执行传入的effect函数,同时响应式追踪其依赖,并在其依赖变更时重新运行该函数。
目的:为了根据响应式状态自动应用和重新应用副作用
注:函数副作用是指当调用函数时,除了返回函数值之外,还对主调用函数产生附加的影响。副作用的函数不仅仅只是返回了一个值,而且还做了其他的事情
如下代码中,副作用函数的作用是:当 count 被访问时,旋即在控制台打出日志。
1 | const count = ref(0) |
副作用刷新时机
默认情况下,会在所有的组件 update 前执行,如果需要在组件更新后重新运行侦听器副作用,可以传递带有 flush 选项的附加 options 对象 (默认为 'pre')
1 | // fire before component updates |
停止侦听
watchEffect会返回一个用于停止这个监听的函数。
这个函数可以在组件被卸载时隐式调用:当
watchEffect在组件的 setup() 函数或生命周期钩子被调用时,侦听器会被链接到该组件的生命周期,并在组件卸载时自动停止也可以在
setup函数里被显式调用,以停止侦听1
2
3
4
5
6const stop = watchEffect(() => {
/* ... */
})
// later
stop()
清除副作用
有时副作用函数会执行一些异步的副作用,比如当数据变化时发送一次异步请求,如果请求过程中数据发生多次变化,那么就得多次发送请求。这样不仅浪费资源,还会因无法判断异步请求的执行顺序增加不确定性。
为了解决这个问题,watchEffect副作用传入的函数接收一个onInvalidate函数作为入参,用来注册清理失效时的回调。
onInvalidate只作用于异步函数,并且只有在如下两种情况下才会被调用:
- 副作用即将重新执行时
- 侦听器被停止 (如果在
setup()或生命周期钩子函数中使用了watchEffect,则在组件卸载时)
我的理解中对异步副作用的清除就相当于函数防抖,执行这一次的副作用时,清理上一次的异步副作用,使得之前挂起的异步操作无效。
1 | watchEffect(onInvalidate => { |
Vue3 之所以是通过传入一个函数去注册失效回调,而不是从回调返回它,是因为返回值对于异步错误处理很重要。
在执行数据请求时,副作用函数往往是一个异步函数:
1 | const data = ref(null) |
watchEffect和watch的区别
-
watchEffect不需要指定监听的属性,他会自动的收集依赖, 只要我们回调中引用到了 响应式的属性, 那么当这些属性变更的时候,这个回调都会执行,而watch只能监听指定的属性而做出变更(v3开始可以同时指定多个)。 watch访问侦听状态变化前后的值。watch可以懒执行回调:watchEffect如果存在,组件初始化的时候就会执行一次用以收集依赖(与computed同理),而后收集到的依赖发生变化,这个回调才会再次执行,而 watch 不需要,因为他一开始就指定了依赖。
总结
- 生命周期钩子:onMounted / onUpdated
v-for中绑定ref- watchEffect的使用(注意其副作用的刷新时机)
参考
官方文档:https://vue3js.cn/docs/zh/guide/reactivity-computed-watchers.html#watcheffect