背景
近几天有位小伙伴问到我,他在用 vue3 实现 tabs 组件时遇到一些困惑,让我帮忙看看。
他的实现方式是:想在 tabs 组件中,直接去修改 tab-panel 的 show/hide ,结果没有成功。
// tabs 组件
// template 中使用 <slot></slot>
setup(props, context) {
// 省略其他...
function changeActive() {
context.slots.default().map(item => {
// 省略其他...
// 重点看这里
item.type().setup().show.value = true // or false
})
}
}
虽然我之前也没有使用 vue3 做过 tabs 组件,但是这种方式显然是有问题的。
- 直接调用
.setup()
函数,那么它的参数(props
context
)呢? —— 很显然,setup
并不适合直接让使用者调用 - 父组件直接去修改子组件的值,这本身就是一种反模式。不符合当前 MVVM 或者“数据驱动视图”的设计思想,而更像是 jQuery 直接操作 DOM 的思路。
提出了问题,但该如何解决呢?我一眼也看不出来,于是就动手实践一下,并分享给大家。
tabs 的应用
凡事先看(或假想)结果,再去思考如何内部实现。
假如 tabs 组件已经制作完成,用户该如何使用它呢?这里我们直接参考 ant-design-vue 的使用方式。
<template>
<tabs default-active-key="1" @change="onTabsChange">
<tab-panel key="1" title="title1">tab panel content 1</tab-panel>
<tab-panel key="2" title="title2">tab panel content 2</tab-panel>
<tab-panel key="3" title="title3">tab panel content 3</tab-panel>
</tabs>
</template>
实现出来的效果如下图,没写 css 样式。可切换 tab ,并实现 tab header 的高亮显示。
PS:ant-design-vue 和 element-plus 的 tabs 组件,还有很多配置和属性,这里暂且不考虑,只实现 tabs 最基本切换和展示的功能。
开发
tab-panel 组件
这个组件其实非常简单,没有啥逻辑。
<template>
<slot></slot>
</template>
<script>
export default {
name: 'TabPanel',
props: ['key', 'title'],
}
</script>
tabs 组件
tabs 组件稍微复杂一点,总结一下它的功能点:
- 根据 tab-panel 的 title ,显示 tab header (即效果图的那几个 button)
- tab header 点击可切换:1. active button 高亮显示;2. 却换时触发
change
事件 - tab header 切换时,实时显示对应的 tab-panel ,并隐藏其他的 tab-panel
第一个功能,获取 slot 组件即可实现,比较简单。
<template>
<div>
<button
v-for="titleInfo in titles"
:key="titleInfo.key"
>
{{titleInfo.title}}
</button>
</div>
<slot></slot>
</template>
<script>
export default {
name: 'Tabs',
props: ['defaultActiveKey'],
setup(props, context) {
// 获取 slots 里的 key 和 title
const panels = context.slots.default()
const titles = panels.map(child => {
const { props = {} } = child
const { key, title } = props
return {
key,
title
}
})
return {
titles
}
}
}
</script>
第二个功能,点击 button 时切换 active key ,并根据 active key 判断哪个 button 高亮显示,也比较简单。
<template>
<div>
<button
v-for="titleInfo in titles"
:key="titleInfo.key"
@click="changeActKey(titleInfo.key)"
:style="{ color: titleInfo.key === actKey ? 'blue' : '#333' }"
>
{{titleInfo.title}}
</button>
</div>
<slot></slot>
</template>
<script>
import { ref } from 'vue'
export default {
name: 'Tabs',
props: ['defaultActiveKey'],
emits: ['change'],
setup(props, context) {
// 获取 slots 里的 key 和 title
const panels = context.slots.default()
const titles = panels.map(child => {
const { props = {} } = child
const { key, title } = props
return {
key,
title
}
})
// 当前 actKey
const actKey = ref(props.defaultActiveKey)
function changeActKey(key) {
actKey.value = key
context.emit('change', key) // 触发回调函数
}
return {
titles,
actKey,
changeActKey,
}
}
}
</script>
但是在写第三个功能时,我遇到了问题。因为 <slot>
是一个整体,里面是所有的 tab-panel
组件,如何筛选出 active panel 呢?一时没有找到解决方案。
使用 JSX
现在已经有了 actKey
,也可以获取 slots
所有组件,而且知道它们 props.key
—— 所有的数据都具备,就差写逻辑了。
既然 template 无法实现这个逻辑,就尝试转为逻辑更灵活的 JSX ,于是就能轻松实现了。
可重点看代码中 panels.filter(...)
部分,逻辑非常简单。
setup() {
// 省略 N 行,和上文一样的
// 渲染视图
return () => <>
<div>
{/* 渲染 buttons */}
{titles.map(titleInfo => {
const { key, title } =titleInfo
return <button
key={key}
onClick={() => changeActKey(key)}
style={{ color: actKey.value === key ? 'blue' : '#333' }}
>{title}</button>
})}
</div>
<div>
{/* 筛选,显示哪个 panel */}
{panels.filter(panelComponent => {
const { props = {} } = panelComponent
const { key } = props
if (actKey.value === key) return true
return false
})}
</div>
</>
}
关于 JSX 的一些思考
记得当年 React 开始流行时,有些人批判说:React 的 JSX 就是一种反模式,全世界花费了很多年终于把 html 和 javascript 分开,你又给混合起来了。
同时,有人就很推崇 Vue(那会儿是 V1 或 V2 版本),说 Vue 把 template script 分的很清楚,很像 html 页面的开发方式。
但是,不知道大家有没有发现,自从 Vue3 发布并开始慢慢时候之后,又有很多人开始推崇使用 Vue3 写 JSX 。印象比较深的是 Jokcy 老师的这篇文章 为什么我推荐使用JSX开发Vue3 。
这些说法都没有错,都是基于自己的使用场景来说的。
但如果以上帝视角放眼全局并考虑时间因素来看,你会发现 JSX 并不是一种反模式或者退步,它是一种伟大的创新。
否则它也不会脱离 React 而晋升为一种编码标准。
当你开发的 UI 交互比较常规,结构比较传统,template 会非常友好(当然 JSX 也能胜任)。
不过,当你开发的 UI 逻辑比较复杂时,template 不一定能很友好、低成本的解决,而 JSX 可以让你实现所有逻辑(只要是 js 能实现的)。
所以,推荐使用 JSX,+1