vue3+jsx使用递归组件实现无限级菜单

评论放链接好像会被吞,demo地址:https://gitee.com/zqw127/vue3-jsx-project

之前我用vue2实现了一版,地址为:vue中使用递归组件实现无限级菜单
想要实现的功能就是根据路由信息自动生成对应的菜单。

这次用vue3+jsx再实现一版,思路没有变化,但是写起来基本完全不同了,主要变化有:
1.composition api写法与vue2中的区别
2.使用jsx+ts
3.router变化
最主要的变化还是第二个,下面我会把涉及到的内容以我的理解讲出来,如果有理解更到位的大佬,欢迎指教哦。当然最基础的jsx与ts用法下面就不说了。至于jsx写法与传统vue文件写法两者的优缺点,网上的大佬已经说了很多了,主要看自己喜欢,我觉得可以不用,但不能问起来说不出(懂的·都懂)。

vue中使用jsx

1.安装@vue/babel-plugin-jsx

npm run @vue/babel-plugin-jsx --save

2.在项目的babel.config.js中的plugins添加,下面是在脚手架生成文件基础下添加:

module.exports = {
  presets: ['@vue/cli-plugin-babel/preset'],
  plugins: ['@vue/babel-plugin-jsx']
}

3.创建tsx文件以及用法
使用ts写jsx的文件就是tsx文件,用tsx文件来代替我们平常写的vue文件,下面是两种文件的区别:
.vue文件:

<template>
  <div>{{bar}}</div>
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue'
export default defineComponent({
  setup () {
    const bar = ref('hello')
    return {
      bar
    }
  }
})
</script>

.tsx文件:

import { defineComponent, ref } from 'vue'
export default defineComponent({
 setup () {
    const bar = ref('hello')
    return () => {
      return <div>{bar.value}</div>
    }
  }
})

上面就是简单的用法区别,当setup返回一个函数时,就是返回一个render函数,这个函数返回的内容就当作组件的模板使用,下面是一些jsx在vue中基础用法:

v-bind或props:
<div data={data}></div> // 变量与js表达式需要用一对大括号引起来

v-for:
{
  [1,2,3].map((item) => {
    return <div key={item}>{item}<div>
  })
}

v-if:
{ flag ? <div>超人鸭<div> : null }

v-on:
const fn = () => {
  .....
}
<div onClick={fn}>点击</div>
需要传递参数:
<div onClick={() => { fn(111) }}>点击</div>

class:
<div class="a">超人鸭</div>

基础的用法就是这些,还有v-model、v-show我们的插件内部已经实现了,可以直接用,具体可以看@vue/babel-plugin-jsx,插槽的用法在下面会说到。

功能介绍以及实现思路

上面说到,实现的功能就是根据路由信息使用递归组件实现无限级菜单,其实就是去生成菜单组件,先看看生成的菜单:

image.png

菜单其实只有两种状态:
1.菜单目录,下面还有子菜单的,展现出来就是点击可以收缩子菜单项(上图中的用户管理、菜单1、菜单1-2)
2.菜单项,就是没有下一级了,点击可以跳转到具体页面。
整个菜单就是由这两种组件组成,我使用element-ui中的导航组件来实现:
菜单目录:el-submenu
菜单项:el-menu-item
而菜单目录中可以任意嵌套菜单目录和菜单项,el-submenu也是可以的。
注:vue3中用的是element-plus哦,如果对这个组件不熟悉建议先看一下文档哦,导航组件文档

然后是路由信息
下面是在脚手架生成的 router/index.ts文件中改 routes对象而已,其他配置不动。

export const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    name: 'LAYOUT_VIEW',
    component: Layout,
    meta: { isLogin: true, hidden: true },
    redirect: '/user'
  },
  {
    path: '/user',
    name: 'USER_MANAGE',
    component: Layout,
    meta: { title: '用户管理', icon: 'el-icon-s-tools', alwaysShow: true },
    redirect: '/user/info',
    children: [
      {
        path: 'info',
        name: 'USER_INFO',
        component: () => import('@/views/userInfo/index'),
        meta: { title: '用户信息' }
      }
    ]
  },
  {
    path: '/test1',
    name: 'TEST1',
    component: Layout,
    meta: { title: '菜单1', icon: 'el-icon-s-tools', alwaysShow: true },
    children: [
      {
        path: 'test1-1',
        name: 'TEST1-1',
        component: () => import('@/views/test.vue'),
        meta: { title: '菜单1-1' }
      },
      {
        path: 'test1-2',
        name: 'TEST1-2',
        component: () => import('@/views/test.vue'),
        meta: { title: '菜单1-2', alwaysShow: true },
        children: [
          {
            path: 'test1-2-1',
            name: 'TEST1-2-1',
            component: () => import('@/views/test.vue'),
            meta: { title: '菜单1-2-1' }
          }
        ]
      }
    ]
  },
  {
    path: '/test2',
    name: 'TEST2',
    component: Layout,
    meta: { title: '菜单2', icon: 'el-icon-s-tools', alwaysShow: false },
    children: [
      {
        path: 'test2-1',
        name: 'TEST2-1',
        component: () => import('@/views/test.vue'),
        meta: { title: '菜单2-1' }
      }
    ]
  },
  {
    path: '/login',
    name: 'LOGIN',
    component: () => import('@/views/login/index.vue'),
    meta: { isLogin: false, hidden: true }
  }
]

这个路由信息就对应了上面生成的菜单组件。用来生成菜单组件最主要的逻辑就是根据路由有没有children属性,如果有,那就是菜单目录,对应el-submenu,如果没有,那就是菜单项,对应el-menu-item。其中layout就是一个放着<router-view>的布局组件,然后关键信息都在每个路由的meta中:

  • title代表菜单的名称
  • icon就是图标的类名,这里用了element自带的
  • hidden就表示不在菜单中显示,比如登录路由,404页等
  • alwaysShow是一个额外的逻辑,当路由的children只有一项时,默认是直接展示菜单项的,就是不展示那种可以收缩的菜单目录,只有配置了这个alwaysShow属性后才作菜单目录渲染。当然这是我自己的逻辑,可以随意改。

我这样写就要求每个路由信息都要有meta对象。接下来就可以拿着这个路由信息去生成组件了。

编写组件

文件结构:

image.png

因为element的菜单组件最外层是一个<el-menu></el-menu>,所以在index.tsx中编写外部包裹的组件,sidebarItem就是真正实现递归逻辑的组件。

先看index.tsx:

import { defineComponent, computed } from 'vue'
import '../style/sidebar.scss'
import { routes } from '@/router/index' // 将在router中定义的routes引入
import { useRoute } from 'vue-router'
import SidebarItem from './sidebarItem'

export default defineComponent({
  setup () {
    // 过滤掉第一层不显示的路由,比如登录路由
    const isShowRoutes = computed(() => {
      return routes.filter((item) => {
        return !item.meta!.hidden
      })
    })
    // 当前路由的路径,为和el-menu的高亮项对应
    const currentPath = computed(() => {
      return useRoute().path
    })

    return () => {
      return <div class="layout-sidebar-wrapper">
        <el-scrollbar style="height:100%">
          <el-menu default-active={currentPath.value}
            backgroundColor="#304156"
            text-color="#bfcbd9"
            unique-opened={false}
            active-text-color="#409EFF"
            collapse-transition={false}
            mode="vertical">
            {
              isShowRoutes.value.map((route) => {
                return <SidebarItem item={route}
                  basePath={route.path}
                  key={route.path}>
                </SidebarItem>
              })
            }
          </el-menu>
        </el-scrollbar>
      </div>
    }
  }
})

这代码块没高亮看着挺难受,下面把几个代码解释一下以及与vue2写法的区别
上面也说到在setup返回一个函数就是渲染函数,在里面返回组件的模板,其他逻辑没变。
使用路由,需要在vue-router中引入:

vue2获取当前路由的信息:
this.$route

vue3:
import { useRoute } from 'vue-router'
const route = useRoute()

vue2路由跳转:
this.$router.push('/')
or
this.$router.push({
  path: '/'
})

vue3:
import { useRouter } from 'vue-router'
const router = useRouter()
router.push('/')

计算属性:

vue2:
computed: {
  currentPath() {
    return this.$route.path
  }
}

vue3:
import { computed } from 'vue'
const currentPath = computed(() => {
  return useRoute().path
})
使用需要:
currentPath.value

类型断言,ts中的一个语法,因为ts不确定我们每个路由都有meta信息,所以上面第一个计算属性中的item.meta可能为undefined,所以在后面加上一个!表示我们确定这个属性一定存在:

item.meta!.hidden

最后将isShowRoutes去遍历渲染<SidebarItem>组件,就相当于v-for<SidebarItem>组件要接收一个item也就是一个路由对象信息,一个basePath就是每一个路由信息的基础路径这两个props。这里使用tsx就有一个明显的优点,会对props作类型校验。具体可以自己去试一下。

sidebarItem.tsx

import { defineComponent, PropType } from 'vue'
import { RouteRecordRaw, useRouter } from 'vue-router'
import path from 'path'

const SidebarItem = defineComponent({
  name: 'SidebarItem',
  props: {
    item: {
      type: Object as PropType<RouteRecordRaw>,
      required: true
    },
    basePath: {
      type: String,
      required: true
    }
  },
  setup (props) {
    const router = useRouter()

    let data: Partial<RouteRecordRaw> = { // 存储当前路由的处理后的信息
    }

    const resolvePath = (routePath: string): string => {
      return path.resolve(props.basePath, routePath)
    }

    const navigation = (path: string) => {
      router.push(path)
    }

    return () => {
      const handleRoute = () => {
        const { item } = props

        // 最后一层的情况,渲染菜单项
        if (!item.children) {
          data = { ...item, path: '' }
          return <el-menu-item onClick={() => { navigation(resolvePath(data.path!)) }} index={resolvePath(data.path!)}>
            <span>{data.meta!.title}</span>
          </el-menu-item>
        }
        
        // 把当前这一层路由中的children的hidden过滤点
        const showingChildren = item.children.filter((item) => {
          return item.meta && !item.meta.hidden
        })
        item.children = showingChildren
        
        // 如果当前路由只有一个children并且这个children没有children属性,并且没有设置alwaysShow这个属性,当菜单项渲染
        if (showingChildren.length === 1 && !showingChildren[0].children && (item.meta && !item.meta.alwaysShow)) {
          data = showingChildren[0]
          return <el-menu-item index={resolvePath(data.path!)} onClick={() => { navigation(resolvePath(data.path!)) }}>
            <span>{data.meta!.title}</span>
          </el-menu-item>
        }

        const slots = {
          title: () => {
            return <div>
              {item.meta!.icon ? <i class={item.meta!.icon}></i> : null}
              <span>{item.meta!.title ? item.meta!.title : '未定义菜单名称'}</span>
            </div>
          }
        }
        
        // 有children属性,没其他特殊情况,作菜单目录渲染,递归引用
        return <el-submenu index={resolvePath(item.path)} v-slots={slots}>
          {item.children.map((child) => {
            return <SidebarItem item={child} basePath={resolvePath(child.path)} key={child.path}></SidebarItem>
          })}
        </el-submenu>
      }
      return <div>{handleRoute()}</div>
    }
  }
})

export default SidebarItem

这个组件就比较复杂了,下面从上到下把几块解释一下:

  • PropType
vue中定义props的类型只能定义js的基本类型,对于Object、Array、Function之类的类型在ts检验中基本等于没用,所以需要PropType来定义具体的类型

用法:
item: {
  在PropType的<>填入具体的类型
  type: Object as PropType<RouteRecordRaw>,
  required: true
}

RouteRecordRaw是路由信息的类型,是vue-router自带的,把它引入就行:
import { RouteRecordRaw } from 'vue-router'
  • Partial
Partial是ts的一个语法,将传入的类型全部变成可选的,来避免一些麻烦

用法:
interface Type{
  a: string;
  b: number;
}
let c: Partial<Type> = {}
此时c的类型就是:
{
  a?: string;
  b?: number;
}
  • 递归组件
    我们在vue2中使用递归组件只要声明了name属性就可以直接在template中使用,但是在tsx中,使用递归组件需要明确的定义:
const SidebarItem = defineComponent({
  setup(){
    return () => {
      return <SidebarItem></SidebarItem>
    }
  }
})
最后别忘了把组件导出:
export default SidebarItem
  • 插槽
    image.png

    上面这一块的就是插槽的引入,先看看我们原本插槽的写法,直接看element官方对el-submenu的写法:
<el-submenu index="1">
  <template v-slot:title>
    <i class="el-icon-location"></i>
    <span>导航一</span>
  </template>
  <el-menu-item index="1-1">选项1</el-menu-item>
  <el-menu-item index="1-2">选项2</el-menu-item>
  <el-menu-item index="1-3">选项3</el-menu-item>
</el-submenu>

这就是我们之前的写法,el-submenu就是有一个叫title的具名插槽,换成tsx写法就是我上面那种,通过传入一个对象,对象里面配置各个函数,每个函数返回要插入的dom,具体可以看jsx的介绍文档,各种用法都挺详细的:

image.png

  • 注意点
    上面有一个navigation函数,作用就是点击菜单项进行路由跳转,我一开始是这样写的:
const navigation = (path: string) => {
  const router = useRouter()
  router.push(path)
}

就是将路由的声明放在方法中,但是这样router会变成undefined,需要放在setup外层声明,可能是跟执行的时机有关,setup会在相当于vue2的beforeCreate和create之间的这个时期执行,但是和路由具体的关系我还不太清楚,待我去研究一哈。

这样整个功能就实现了,之后只需要在路由添加配置路由信息,菜单就会自动生成。欢迎指教哦。

补充:jsx中子组件怎么写插槽

上面在实现功能中讲到了在jsx中插槽如何使用,但都是在使用别人的插槽,emmm也就是一直插别的组件,也就是在父组件中使用插槽。那我们如何在jsx定义自己的插槽呢,其实就是在子组件中写插槽。

  1. 先看看之前在.vue文件中如何写插槽:
    默认插槽:
在子组件:
<div>
    <span>children</span>
    <slot></slot>
</div>

在父组件:
<children>
    哈哈哈
</children>
哈哈哈就会填充到子组件的slot标签里面

具名插槽

在子组件:
<div class="container">
  <header>
    <slot name="header"></slot>
  </header>
  <div>
    <slot></slot>
  </div>
</div>

在父组件:
<children>
  <template v-slot:header>
    <h1>Here might be a page title</h1>
  </template>
  <p>A paragraph for the main content.</p>
  <p>And another one.</p>
</children>

v-slot 只能添加在 <template>标签上
任何没有被包裹在带有 v-slot 的 <template> 中的内容都会被视为默认插槽的内容,比如上面的两个p标签,就会渲染在默认插槽中。
默认插槽其实有个default的名字,所以上面的父组件也可以写成:

<children>
  <template v-slot:header>
    <h1>Here might be a page title</h1>
  </template>
  <template v-slot:default>
    <p>A paragraph for the main content.</p>
    <p>And another one.</p>
  </template>
</children>

作用域插槽
作用域插槽就是子组件通过插槽把子组件里面的变量传递给父组件

在子组件里面:
<span>
  <slot name="user" v-bind:user="user">
  </slot>
</span>
data(){
  return: {
    user: {
      name: '超人鸭'
    }
  }
}

在父组件中:
<children>
  // slotProps是自己起的名字,可以随便起
  <template v-slot:user="slotProps">
      {{ slotProps.user.name}}
  </template>
</children>

在子组件中的slot标签都可以绑定很多变量,vue会把这些变量都放在一个对象上,然后在父组件上通过这个对象去点出来绑定的变量,既然是一个对象,那就可以解构,所以上面父组件的写法可以变成下面这样:

在父组件中:
<children>
  <template v-slot:user="{user}">
      {{ user.name}}
  </template>
</children>

上面就是在.vue文件中使用插槽的写法,下面直接看在jsx中如何写插槽:
默认插槽

在子组件:
export default defineComponent({
  setup (props, { slots }) {
    return () => {
      return <div>{slots}</div>
    }
  }
})

在setup的第二个参数是一个对象,里面有一个slots,感觉它就是为了jsx写法而存在的(个人目前见解)
父组件的用法上面已经说到了,这里再写一遍吧,默认插槽其实有个被省略的名字:default ,所以父组件中对默认插槽有两种用法:

1.
<children>
  <div>超人鸭</div>
</children>

2.
const slots = {
  default: () => {
    return <div>超人鸭</div>
  }
}
<children v-slots={slots}>
</children>

具名插槽:

在子组件
export default defineComponent({
  setup (props, { slots }) {
    return () => {
      return <div>
        <div>{slots}</div>
        // 这里需要做个判断,只有存在才去渲染
        <div>{slots.header ? slots.header() : null}</div>
      </div>
    }
  }
})

在父组件:
const slots = {
  default: () => {
    return <div>超人鸭</div>
  },
  header: () => {
    return <div>header</div>
  }
}
<children v-slots={slots}>
</children>

使用jsx后其实比较清晰,父组件传递给子组件的插槽其实是个函数,然后在子组件里面执行,渲染函数返回的dom,还有一个注意的地方,在子组件中写slots要单独包裹一个标签。
作用域插槽:
上面在说具名插槽的时候说到,插槽其实就是子组件中去执行父组件传递进来的函数,那把父组件中要使用子组件中的变量只需要把变量放在函数的参数上就可以。

在子组件:
export default defineComponent({
  setup (props, { slots }) {
    const list = reactive(
      ['1', '2', '3']
    )
    return () => {
      return <div>
        <div>{slots}</div>
        <div>{slots.header ? slots.header() : null}</div>
        <div>{slots.list ? slots.list(list) : null}</div>
      </div>
    }
  }
})

在父组件:
const slots = {
  default: () => {
    return <div>超人鸭</div>
  },
  header: () => {
    return <div>header</div>
  },
  list: (list: Array<any>) => {
    return list.map((item) => {
      return <div>{item}</div>
    })
  }
}
<children v-slots={slots}>
</children>

渲染:


image.png
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,716评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,558评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,431评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,127评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,511评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,692评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,915评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,664评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,412评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,616评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,105评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,424评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,098评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,096评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,869评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,748评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,641评论 2 271

推荐阅读更多精彩内容