Vue3造轮子(四)-Tabs组件

效果预览

代码链接

提交历史

API设计

需求

  • tabs的子组件只能是tab组件

  • 点击标签切换,动态渲染内容 – v-model:selected,动态组件

  • tab标签下横线移动,且长度根据标签宽度变化

用户怎么用该组件

1
2
3
4
5
6
<x-tabs v-model:selected="selectTab">
<x-tab label="星星" name="star">一颗小星星</x-tab>
<x-tab label="羊" name="sheep">羊羊羊</x-tab>
<x-tab label="咩咩" name="mie">咩咩是全世界最可爱的猫咪</x-tab>
</x-tabs>

实现过程

从需求出发,将每个需求分别拆分成几个小问题一一解决

如何确认子组件的类型

要解决这个问题,首先得知道如何拿到子组件。由于Tab组件都是通过默认插槽的形式和Tabs组件一起使用,因此只要拿到Tabs组件的插槽就可以了。

Vue2中,是使用$slots来获取插槽。而在Vue3中,通过文档可知,setup函数接受的第二个参数context中,暴露了slots这个属性,因此可通过context.slots后获取插槽,再通过组件的name判断组件类型

1
2
3
4
5
6
const defaults = context.slots.default()
defaults.forEach(pane => {
if (pane.type.name !== 'xx-tab') {
throw new Error('Tabs 子标签必须是 Tab 组件')
}
})

如何动态渲染嵌套的组件

拿到了默认插槽(tab组件)后,首先想到的是通过循环渲染,然后比较子组件的name属性和selected的值过滤未选中的组件内容。

然而这样会遇到一个问题:将v-forv-if一起使用了。官方文档并不推荐这样做

因此,先通过计算属性拿到当前选中的组件,再通过动态组件渲染

1
<component :is="currentTab" :key="selected" />
1
2
3
4
5
6
setup(){
const currentTab = computed(() => {
return defaults.filter(pane => pane.props.name === props.selected)[0]
})
return { currentTab }
}

:如果渲染的内容改变,必须要将key标识也改变,否则将无法动态变化

如何制作移动的导航条

确定标签导航的html结构

1
2
3
4
5
6
7
8
9
10
11
<div class="xx-tabs-nav">
<!-- 标签 -->
<div class="xx-tabs-nav-item"
v-for="(pane,index) in tabPanes"
:class="{ selected: pane.name === selected }"
:key="index" @click="changeTab(pane.name)">
{{ pane.label }}
</div>
<!-- 导航条 -->
<div class="xx-tabs-nav-indicator"></div>
</div>

根据需求,导航条的宽度应为当前选中标签的宽度,而导航条的位置应该是选中标签的left - 导航容器的left。那么问题就变成了:如何获取导航容器,导航条,选中标签这三个DOM元素呢?

如何获取DOM元素

Vue2中,可以使用ref$refs来获取DOM元素,而在Vue3中有了新变化。如果在v-for中使用ref,需要将 ref 绑定到一个更灵活的函数上。

基于此,可以使用如下方式创建设置导航条属性的方法:

1
2
3
4
5
6
7
8
9
10
<div class="xx-tabs-nav" ref="navContainer">
<div class="xx-tabs-nav-item"
v-for="(pane,index) in tabPanes"
:class="{ selected: pane.name === selected }"
:ref="el => { if (pane.name === selected) selectedItem = el }"
:key="index" @click="changeTab(pane.name)">
{{ pane.label }}
</div>
<div class="xx-tabs-nav-indicator" ref="indicator"></div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
setup(){
const selectedItem = ref<HTMLDivElement>(null)
const indicator = ref<HTMLDivElement>(null)
const navContainer = ref<HTMLDivElement>(null)
const getIndicatorAttr = () => {
const { width } = selectedItem.value.getBoundingClientRect()
const { left: containerLeft } = navContainer.value.getBoundingClientRect()
const { left: selectedItemLeft } = selectedItem.value.getBoundingClientRect()
const left = selectedItemLeft - containerLeft
indicator.value.style.left = left + 'px'
indicator.value.style.width = width + 'px'
}
const count = ref(0)
}

如何实现宽度动态变化

很容易想到,当控制标签选中的变量selected发生变化后,生命周期函数updated即会执行,因此我们可以在组件挂载完成及更新完成时分别调用getIndicatorAttr方法实现宽度的动态变化。

在Vue 3中,可以使用直接导入的 onX 函数注册生命周期钩子:

1
2
3
4
5
6
import { onMounted, onUpdated } from 'vue'

setup(){
onMounted(getIndicatorAttr)
onUpdated(getIndicatorAttr)
}

如何优化

虽然需求已经实现,但问题又来了:这段代码还有没有优化空间呢?

于是想到,能不能通过监听selected的变化来实现getIndicatorAttr方法的调用呢?

查阅文档发现,Vue3还提供了一个新特性——watchEffect响应式追踪其依赖的变化。于是根据文档进行了尝试:

1
2
3
4
5
6
7
8
9
10
11
12
13

const count = ref(0)
onMounted(()=>{
console.log('onMounted');
})
watchEffect(() => console.log(count.value))

setTimeout(() => {
count.value++
}, 100)
// -> 0
// -> onMounted
// -> 1

可以看到watchEffect会在onMounted执行前调用一次,并在所传入函数中的依赖发生变化时再次被调用。

但DOM元素在组件挂载完成后才能获取到,因此采取了这样的调用方式:

1
2
3
onMounted(()=>{
watchEffect(getIndicatorAttr())
})

然而,当我欣喜地期待着功能完美实现的时候,马上就打脸了:

怎么不按剧本来呢

解决使用watchEffect的bug

这是什么情况?马上打console看了看:

1
2
3
4
5
6
7
8
9
10
onMounted(()=>{
console.log('onMounted');
watchEffect(()=>{
console.log(selectedItem.value);
getIndicatorAttr()
}, { flush:'pre' }) // Vue3正式版默认flush为pre(即在渲染前执行watchEffect)
})
onUpdated(()=>{
console.log('onUpdated');
})

重新梳理逻辑:

原本是期望selected变化导致selectedItem变化,之后watchEffect执行,改变indicator位置。实际上却是:watchEffectselectedItem变化前就执行了,发现其还未变化,因此传入的函数并未立即调用。

查阅文档发现确实如此:

Vue 的响应性系统会缓存副作用函数,并异步地刷新它们,这样可以避免同一个“tick” 中多个状态改变导致的不必要的重复调用。在核心的具体实现中,组件的 update 函数也是一个被侦听的副作用。当一个用户定义的副作用函数进入队列时,默认情况下,会在所有的组件 update 执行

文档也给出了相应的解决方案:

如果需要在组件更新重新运行侦听器副作用,可以传递带有 flush 选项的附加 options 对象 (默认为 'pre'):

1
2
3
4
5
6
7
8
9
// fire before component updates
watchEffect(
() => {
getIndicatorAttr()
},
{
flush: 'post'
}
)

问题解决~

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
2
3
4
5
6
7
8
9
10
11
12
13
14
import { ref, onBeforeUpdate, onUpdated } from 'vue'

export default {
setup() {
let itemRefs = []
const setItemRef = el => {
itemRefs.push(el) // 这里的el即为循环的DOM元素
}
return {
itemRefs,
setItemRef
}
}
}

watch

watch 需要侦听特定的数据源,并在回调函数中执行副作用。默认情况下,它也是惰性的,即只有当被侦听的源发生变化时才执行回调

侦听单个数据源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 侦听一个 getter
const state = reactive({ count: 0 })
watch(
() => state.count,
(count, prevCount) => {
/* ... */
}
)

// 直接侦听ref
const count = ref(0)
watch(count, (count, prevCount) => {
/* ... */
})

侦听多个数据源

1
2
3
watch([fooRef, barRef], ([foo, bar], [prevFoo, prevBar]) => {
/* ... */
})

watchEffect

watchEffect方法接收的第一个参数:effect函数,用于定义副作用。他会立即执行传入的effect函数,同时响应式追踪其依赖,并在其依赖变更时重新运行该函数。

目的:为了根据响应式状态自动应用重新应用副作用

注:函数副作用是指当调用函数时,除了返回函数值之外,还对主调用函数产生附加的影响。副作用的函数不仅仅只是返回了一个值,而且还做了其他的事情

如下代码中,副作用函数的作用是:当 count 被访问时,旋即在控制台打出日志。

1
2
3
4
5
6
7
8
9
const count = ref(0)

watchEffect(() => console.log(count.value))
// -> logs 0

setTimeout(() => {
count.value++
// -> logs 1
}, 100)

副作用刷新时机

默认情况下,会在所有的组件 update 执行,如果需要在组件更新重新运行侦听器副作用,可以传递带有 flush 选项的附加 options 对象 (默认为 'pre')

1
2
3
4
5
6
7
8
9
// fire before component updates
watchEffect(
() => {
/* ... */
},
{
flush: 'post'
}
)

停止侦听

watchEffect会返回一个用于停止这个监听的函数。

  • 这个函数可以在组件被卸载时隐式调用:当 watchEffect 在组件的 setup() 函数或生命周期钩子被调用时,侦听器会被链接到该组件的生命周期,并在组件卸载时自动停止

  • 也可以在setup函数里被显式调用,以停止侦听

    1
    2
    3
    4
    5
    6
    const stop = watchEffect(() => {
    /* ... */
    })

    // later
    stop()

清除副作用

有时副作用函数会执行一些异步的副作用,比如当数据变化时发送一次异步请求,如果请求过程中数据发生多次变化,那么就得多次发送请求。这样不仅浪费资源,还会因无法判断异步请求的执行顺序增加不确定性。

为了解决这个问题,watchEffect副作用传入的函数接收一个onInvalidate函数作为入参,用来注册清理失效时的回调。

onInvalidate只作用于异步函数,并且只有在如下两种情况下才会被调用:

  • 副作用即将重新执行时
  • 侦听器被停止 (如果在 setup() 或生命周期钩子函数中使用了 watchEffect,则在组件卸载时)

我的理解中对异步副作用的清除就相当于函数防抖,执行这一次的副作用时,清理上一次的异步副作用,使得之前挂起的异步操作无效。

1
2
3
4
5
6
7
8
watchEffect(onInvalidate => {
// 异步api调用,返回一个操作对象
const token = performAsyncOperation(id.value)
onInvalidate(() => {
// 取消异步api的调用。
token.cancel()
})
})

Vue3 之所以是通过传入一个函数去注册失效回调,而不是从回调返回它,是因为返回值对于异步错误处理很重要。

在执行数据请求时,副作用函数往往是一个异步函数:

1
2
3
4
5
const data = ref(null)
watchEffect(async onInvalidate => {
onInvalidate(() => {...}) // 在Promise解析之前注册清除函数
data.value = await fetchData(props.id)
})

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

https://www.jianshu.com/p/a8fdf52d0bcf

https://segmentfault.com/a/1190000023669309