zoukankan      html  css  js  c++  java
  • [前端随笔][Vue] 多级菜单实现思路——组件嵌套

    说在前面

    本篇记录学习了vue-element-admin中的多级菜单的实现   [传送门]

    @vue/cli 4.2.2;vuex;scss;组件嵌套

    正文

    创建项目

    npm create 项目名 //或npm init webpack 项目名

    安装element-ui

    npm add element-ui //或npm i element-ui
    

    安装vuex

    npm add vuex //或npm i vuex

    安装完vuex后会出现src/store目录,同时在src/main.js中vue实例添加了store(这里是关于vuex的知识先放一下)


     首先侧边栏的内容哪来?需要根据路由表来展示。

    所以我们需要

    一、 构造子页面并配置路由

    1 在src/views目录建两个目录和三个vue文件book/read.vue,book/write.vue和 movie/watch.vue (template+script构造页面)

    2 接着配置这三个页面的路由如下

    const routes = [
      {
        path: '/book',
        component: Layout,
        redirect: '/book/write',
        children: [
          {
            path: '/book/write',
            component: () => import('@/views/book/write'),
            name: 'book',
            meta: { title: '写书', icon: 'edit', roles: ['admin'] }
          },
          {
            path: '/book/read',
            component: () => import('@/views/book/read'),
            name: 'book',
            meta: { title: '读书', icon: 'edit' }
          }
        ]
      },
      {
        path: '/movie',
        component: Layout,
        redirect: '/movie/watch',
        children: [
          {
            path: '/movie/watch',
            component: () => import('@/views/movie/watch'),
            name: 'movie',
            meta: { title: '看电影', icon: 'edit' }
          }
        ]
      }
    ]
    

    这里面的component:layout是什么呢?


    二、构造主页面(准备引用菜单栏)

    简单说layout它是一个整体的页面结构,比如一个页面有侧边栏也有正文内容,还有顶部底部等,他们都在这里被引入。

    接下来就来实现它:

    建立一个目录和主文件src/layout/index.vue,再建立一个目录src/layout/components存放整体结构下的一些部件,比如侧边栏、设置按钮等。

    这里的index.vue

    <template>
      <div :class="classObj">
        <sidebar class="sidebar-container" />
       <!--页面的其他元素--> </div> </template> <script> import { Sidebar } from './components' import { mapState } from 'vuex' export default { name: 'Layout', components: { Sidebar }, mixins: [ResizeMixin], computed: { ...mapState({ sidebar: state => state.app.sidebar }), classObj() { return { hideSidebar: !this.sidebar.opened, openSidebar: this.sidebar.opened, withoutAnimation: this.sidebar.withoutAnimation } } }, methods: { handleClickOutside() { this.$store.dispatch('app/closeSideBar', { withoutAnimation: false }) } } } </script>

     其中...mapState({})的作用是将store中的getter映射到局部计算属性  [文档传送门]

    handleClickOutside()是控制侧边栏折叠,通过封装element-ui原生代码实现,可删去不细讲。

    sidebar就是我们从./components引入的组件,也就是本篇的主角。


    三、构造菜单栏

    首先要了解element的菜单栏是怎么样子的   [文档传送门]

    可以发现最外层是el-menu,内层可以是el-submenu或者el-menu-item,

    若为el-submenu则其内部包含el-menu-item-group,内部可以继续包含el-submenu或者el-menu-item,如此形成多级菜单。

    但要根据路由来实现,就要获取路由,可以通过vuex中的mapGetters来获取。

    所以Sidebar/index.vue如下

    <template>
      <div>
        <el-scrollbar wrap-class="scrollbar-wrapper">
          <el-menu
            :default-active="activeMenu"
            :collapse="isCollapse"
            :background-color="variables.menuBg"
            :text-color="variables.menuText"
            :unique-opened="false"
            :active-text-color="variables.menuActiveText"
            :collapse-transition="false"
            mode="vertical"
          >
            <sidebar-item v-for="route in permission_routes" :key="route.path" :item="route" :base-path="route.path" />
          </el-menu>
        </el-scrollbar>
      </div>
    </template>
    
    <script>
    import { mapGetters } from 'vuex'
    import SidebarItem from './SidebarItem'
    import variables from '@/styles/variables.scss'
    
    export default {
      components: { SidebarItem },
      computed: {
        ...mapGetters([
          'permission_routes',
          'sidebar'
        ]),
        activeMenu() {
          const route = this.$route
          const { meta, path } = route
          // if set path, the sidebar will highlight the path you set
          if (meta.activeMenu) {
            return meta.activeMenu
          }
          return path
        },
        showLogo() {
          return this.$store.state.settings.sidebarLogo
        },
        variables() {
          return variables
        },
        isCollapse() {
          return !this.sidebar.opened
        }
      }
    }
    </script>

    el-scrollbar是element的隐藏属性,表示如果侧边栏过长会产生自定义的滚动条。

    其中mapGetters方法前面加了三个点(对象展开运算符),使得它可以混入computed属性 (官方解释:使用对象展开运算符将 getter 混入 computed 对象中

    此处mapGetters的作用是将store中的getter映射到局部计算属性。

    至此Sidebar/index.vue获得了路由表,进一步将它传给了内部的sidebar-item组件。

    接着SidebarItem.vue文件如下,这里就是最精华的地方(组件嵌套自己)

    <template>
      <div v-if="!item.hidden"><!--来自于路由配置表-->
        <template v-if="hasOneShowingChild(item.children,item) && (!onlyOneChild.children||onlyOneChild.noShowingChildren)&&!item.alwaysShow">
          <app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path)">
            <el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{'submenu-title-noDropdown':!isNest}">
              <item :icon="onlyOneChild.meta.icon||(item.meta&&item.meta.icon)" :title="onlyOneChild.meta.title" />
            </el-menu-item>
          </app-link>
        </template>
        <!--三个条件:1有且只有一个子项目,2子菜单是否还包含子菜单,3是否必须展示-->
        <!--app-link在meta存在时展示,相当于添加点击功能,即a标签(处理内链还是外链)-->
        <!--item是实际展示,该模版只有一个render()方法来渲染(渲染图标,标题等)-->
        <!--若child小于2执行以上步骤-->
    
        <!--若child大于2执行以下步骤(我调我自己sidebar-item)-->
        <!--插槽title展示父路由(render方法渲染)-->
        <!--sidebar-item遍历child(is-nest控制isNest,决定submenu-title-noDropdown即子菜单各项目的样式是否展示,bath-path处理该菜单各项目的链接)-->
        <el-submenu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body>
          <template slot="title">
            <item v-if="item.meta" :icon="item.meta && item.meta.icon" :title="item.meta.title" />
          </template>
          <sidebar-item
            v-for="child in item.children"
            :key="child.path"
            :is-nest="true"
            :item="child"
            :base-path="resolvePath(child.path)"
            class="nest-menu"
          />
        </el-submenu>
      </div>
    </template>
    
    <script>
    import path from 'path'
    import { isExternal } from '@/utils/validate'
    import Item from './Item'
    import AppLink from './Link'
    
    export default {
      name: 'SidebarItem',
      components: { Item, AppLink },
      props: {
        // route object
        item: {
          type: Object,
          required: true
        },
        isNest: {
          type: Boolean,
          default: false
        },
        basePath: {
          type: String,
          default: ''
        }
      },
      data() {
        // To fix https://github.com/PanJiaChen/vue-admin-template/issues/237
        // TODO: refactor with render function
        this.onlyOneChild = null
        return {}
      },
      methods: {
        hasOneShowingChild(children = [], parent) {
          const showingChildren = children.filter(item => {//把需要显示的children存入showingChildren
            if (item.hidden) {
              return false
            } else {
              // Temp set(will be used if only has one showing child)
              this.onlyOneChild = item
              return true
            }
          })
    
          // When there is only one child router, the child router is displayed by default
          if (showingChildren.length === 1) {
            return true
          }
    
          // Show parent if there are no child router to display
          if (showingChildren.length === 0) {
            this.onlyOneChild = { ... parent, path: '', noShowingChildren: true }//没有子菜单,则用parent覆盖onlyOneChild来展示
            return true
          }
    
          return false//child大于2时,则展示ELSubmenu
        },
        resolvePath(routePath) {
          if (isExternal(routePath)) {
            return routePath
          }
          if (isExternal(this.basePath)) {
            return this.basePath
          }
          return path.resolve(this.basePath, routePath)
        }
      }
    }
    </script>
    SidebarItem.vue

    首先要明确上面这段html分文两部分:v-ifv-else

    v-if部分

    这里的item来源于Sidebar/index.vue中sidebar-item标签的:item属性,即遍历路由表时的当前路由。

    1 若有定义hidden属性将直接不显示;

    2 template有三个条件的判断;

    3 app-link是增加点击跳转的功能,给菜单项添加跳转路径(自定义的,详见Sidebar/Link.vue);

    4 el-menu-item就是element的菜单栏最小单元,再往里面就是文字内容了

    5 item是自定义的,为的是将icon和文字合在一起,所以这里又要定义一个自定义组件。(自定义的,详见Sidebar/Item.vue

    <template>
      <!-- eslint-disable vue/require-component-is -->
      <component v-bind="linkProps(to)"> <!--相当于:is :href :target :rel都写在linkProps中了,因为这里有两套属性,所以写在方法中-->
        <slot />
      </component>
    </template>
    
    <script>
    import { isExternal } from '@/utils/validate'
    
    export default {
      props: {
        to: {
          type: String,
          required: true
        }
      },
      methods: {
        linkProps(url) {
          if (isExternal(url)) {//判断路由是否包含http,即外链。一般内链(路由)是类似相对路径的写法
            return {
              is: 'a',
              href: url,
              target: '_blank',
              rel: 'noopener'
            }
          }
          return {//若是内链(路由),则显示routerLink
            is: 'router-link',
            to: url
          }
        }
      }
    }
    </script>
    Sidebar/Link.vue
    <script>
    export default {
      name: 'MenuItem',
      functional: true,
      props: {
        icon: {
          type: String,
          default: ''
        },
        title: {
          type: String,
          default: ''
        }
      },
      render(h, context) {
        const { icon, title } = context.props
        const vnodes = []
    
        if (icon) {
          vnodes.push(<svg-icon icon-class={icon}/>)
        }
    
        if (title) {
          vnodes.push(<span slot='title'>{(title)}</span>)
        }
        return vnodes
      }
    }
    </script>
    Sidebar/Item.vue

    Link中主要是插槽slot,和对外链内链的区分;

    Item中主要是用render函数渲染。

    至此完成了一级菜单,接下来是子菜单的实现


    v-else部分

    el-submenu下面套用了Sidebar-item,也就是自己调用了自己。

    这里与index下面调用的Sidebar-item区别在于多了is-nest(见注释)

    如此递归调用,遍历子菜单时,还会继续检测子菜单是否有子菜单,有的话继续递归,这样就实现了无限层级的菜单。


    下面补充vuex的内容

    src/store/index.js(代码默认生成如下,结构由StateGettersMutationActions这四种组成)

    import Vue from 'vue'
    import Vuex from 'vuex'
    
    Vue.use(Vuex)
    
    export default new Vuex.Store({
      state: {
      },
      mutations: {
      },
      actions: {
      },
      modules: {
      }
    })

    可以理解为Store是一个容器,Store里面的状态与单纯的全局变量是不一样的,因为无法直接改变store中的状态。想要改变store中的状态,只有一个办法,显式地提交mutation。

    当我们需要不止一个store的时候,为了便于维护,可以建立一个目录存放不同的store模块,再把它引入到store/index.js中。[两步:1建立目录,2引入]

    所以我们在src/store目录下增加一个getters.js(映射)和建立modules目录(该目录用于存放不同store模块,所谓store模块就是一个个的形如src/store/index.js的代码,如上,但具体内容与作用不同),

    getter.js(申明一个getters)

    const getters = {
      sidebar: state => state.app.sidebar,
      permission_routes: state => state.permission.routes,
    }
    export default getters

    接着建立app.js和permission.js

    分别写入如下内容

    import Cookies from 'js-cookie'
    
    const state = {
      sidebar: {
        opened: Cookies.get('sidebarStatus') ? !!+Cookies.get('sidebarStatus') : true,
        withoutAnimation: false
      }
    }
    
    const mutations = {
      TOGGLE_SIDEBAR: state => {
        state.sidebar.opened = !state.sidebar.opened
        if (state.sidebar.opened) {
          Cookies.set('sidebarStatus', 1)
        } else {
          Cookies.set('sidebarStatus', 0)
        }
      },
      CLOSE_SIDEBAR: (state, withoutAnimation) => {
        Cookies.set('sidebarStatus', 0)
        state.sidebar.opened = false
      }
    }
    
    const actions = {
      toggleSideBar({ commit }) {
        commit('TOGGLE_SIDEBAR')
      },
      closeSideBar({ commit }, { withoutAnimation }) {
        commit('CLOSE_SIDEBAR', withoutAnimation)
      }
    }
    
    export default {
      namespaced: true,
      state,
      mutations,
      actions
    }
    app.js
    import { asyncRoutes, constantRoutes } from '@/router'
    
    /**
     * Use meta.role to determine if the current user has permission
     * @param roles
     * @param route
     */
    function hasPermission(roles, route) {
      if (route.meta && route.meta.roles) {//若有配置权限,进行some判断(只要符合一条即为true),判断传过来的角色是否存在
        return roles.some(role => route.meta.roles.includes(role))//传过来的roles符合该route所要求的用户权限,返回true,否则false
      } else {//若未配置权限则默认可以访问,返回true
        return true
      }
    }
    
    /**
     * Filter asynchronous routing tables by recursion
     * @param routes asyncRoutes
     * @param roles
     */
    export function filterAsyncRoutes(routes, roles) {
      const res = []
    
      routes.forEach(route => {
        const tmp = { ...route }//浅拷贝routes
        if (hasPermission(roles, tmp)) {//判断是否有权限访问当前遍历到的route
          if (tmp.children) {
            tmp.children = filterAsyncRoutes(tmp.children, roles)//过滤不该显示的子routes,并更新children
          }
          res.push(tmp)//存入空数组res
        }
      })
    
      return res
    }
    
    const state = {
      routes: [],
      addRoutes: []
    }
    
    const mutations = {
      SET_ROUTES: (state, routes) => {
        state.addRoutes = routes//更新state中的addRoutes,暂时不用
        state.routes = constantRoutes.concat(routes)//将constantRoutes和新的routes进行合并(侧边栏直接用state.routes)
      }
    }
    
    const actions = {
      generateRoutes({ commit }, roles) {
        return new Promise(resolve => {
          let accessedRoutes
          if (roles.includes('admin')) {//角色包含admin
            accessedRoutes = asyncRoutes || []
          } else {//角色不包含admin
            accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
          }
          commit('SET_ROUTES', accessedRoutes)//调用上方mutation中的SET_ROUTES,保存accessedRoutes
          resolve(accessedRoutes)
        })
      }
    }
    
    export default {
      namespaced: true,
      state,
      mutations,
      actions
    }
    permission.js

    最后改写src/store/index.js

    import Vue from 'vue'
    import Vuex from 'vuex'
    
    Vue.use(Vuex)
    
    /****************这一段用来生成modules,见下面解释*******************/ // https://webpack.js.org/guides/dependency-management/#requirecontext const modulesFiles = require.context('./modules', true, /.js$/) //多文件情况下整个文件夹统一导入 //(你创建了)一个module文件夹下面(不包含子目录),能被require请求到,所有文件名以 `.js` 结尾的文件形成的上下文(模块) // you do not need `import app from './modules/app'` // it will auto require all vuex module from modules file const modules = modulesFiles.keys().reduce((modules, modulePath) => { // set './app.js' => 'app' const moduleName = modulePath.replace(/^./(.*).w+$/, '$1') const value = modulesFiles(modulePath) modules[moduleName] = value.default return modules }, {})
    /*****************这一段用来生成modules,见下面解释*******************/ export default new Vuex.Store({ modules, getters }) export default store

    那么这里是如何把modules引入

    1 其中require.context()会返回一个webpack的回调函数对象,

    2 调用该对象内的keys()则会把各个路径以数组方式列出,

    3 对该数组进行reduce()合成为一个总对象,

    这一波操作最后会生成一个包含modules内所有模块的总对象,完成引入(由于我们modules是空的,所以会打印为空。)

    这里为了举例用了vue-element-admin写好的,大概如下。(这里可以看出modules下面有app.js,errorLog.js等等不同作用的store,每一个store都有它自己的四件套StateGettersMutationActions

    到这里就设置好了自己项目的vuex,随着项目增大,需要的内容就会更多,小项目不用vuex更好。

  • 相关阅读:
    桐花万里python路-基础篇-01-历史及进制
    linux-tar归档文件
    python-常识
    目录权限修改
    linux常用命令
    battery-historian 使用方法
    cp --复制命令
    adb与bat混用启动与杀死应用程序
    monkey 命令
    INSERT INTO SELECT 语句
  • 原文地址:https://www.cnblogs.com/cc1997/p/12628715.html
Copyright © 2011-2022 走看看