Vue3 开发一个基础的的 tabs 组件 - 体验 JSX 的灵活性

4,395 阅读4分钟

背景

近几天有位小伙伴问到我,他在用 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 的高亮显示。

image.png

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 呢?一时没有找到解决方案。

image.png

使用 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