1. IView 利用 render 函数实现菜单多级动态嵌套递归展示

1、前言

  • 最近公司做后台管理系统,前端缺乏人员,于是乎叫我这个 Java 程序员充当前端人员,还好本人闲暇的时候对前端新兴的技术也是颇感兴趣,于是乎便尝试了一把 Vue 全家桶、IView 做 UI、利用 webpack 工程化前端项目(这事我早就想尝试一把了),话题扯远了,接下来进入下面的主题吧。

2、问题

  • IView 固然挺强大,可以使用的组件还是比较多的,单页面文件写着也是比较舒服的,但是广大网友都知道,管理系统左侧菜单通常是有层级结构的,而且我们项目是不固定,什么意思呢?就是可能有好几层节点,当然一般也就两三层了,层次多了菜单都找不到。正如前面说到的,这个菜单结构是有层级的,配置文件就是一个嵌套的 json 结构,类似于下面:
const settingMenus = [
    {
        name : 'foo1',
        children : [
            {name : 'foo1-1', attrs : {router : '404'}},
            {name : 'foo1-2', attrs : {router : '404'}}
        ]
    },
    { 
        name : 'foo2',
        children : [
            {name : 'foo2-1', attrs : {router : '404'}},
            {name : 'foo2-1', attrs : {router : '404'}}
            {
                name : 'foo2-2', 
                children : [
                    {name : 'foo2-2-1', attrs : {router : '404'}},
                    {name : 'foo2-3-1', attrs : {router : '404'}}
                ]
            }
        ]
    },
    ...
];    

好了,上面的菜单需要渲染成多级菜单,怎么办呢?查看了一下 IView 文档,发现有嵌套菜单相关组件,但是递归的好像是木有,于是乎想了想:Vue 单页面方式默认是使用html模板的方式渲染的,这个。。。似乎没办法根据这个结构递归呀,找了下网上的解决方案,定义一个父组件和子组件,子组件中子节点的渲染又调用自己,不得不说网友还是比较机智的,不过我还是需要想想有没有什么更优雅的方式呢?

3、解决灵感

既然许多网友的方法我都不是很满意,然而自己也一时间想不出办法!那就去 Vue 官方文档看看,Vue 有没有直接使用函数渲染页面的方法,结果还真被我发现了,这就是 render 方法,文档里面介绍如下:

Vue 推荐在绝大多数情况下使用 template 来创建你的 HTML。然而在一些场景中,你真的需要 JavaScript 的完全编程的能力,这时你可以用 render 函数,它比 template 更接近编译器。

上面这句话我在第一次看的时候还没什么感触,好吧,现在是时候发挥它的作用了,wait..., 让我先看看这玩意儿咋用,经过了 n 分钟之后,终于大概了解这个玩意儿是干嘛的了,似乎是直接创建 VNode 渲染页面,更接近底层。。。不管了不管了,接下来就展示一下我琢磨出来的方法吧,什么都不说了,下面就上解决方案吧。

4、实现代码

创建 CMenuTree 组件,为什么是这个名称呢?因为 Menu、SubMenu、MenuItem 都是 IView 全局注册的组件,不能重啊,前面一个 C 标识一下是自定义组件吧。代码如下。

export default {
    props : {
        data : {
            type : Array,
            default : []
        },
        activeName : {
            type : String,
            required : true
        },
        theme : {
            type : String,
            default : 'light'
        },
        openNames : {
            type : Array,
            required : true
        }
    },

    // 因为菜单渲染需要根据菜单节点进行递归渲染, 使用 render 函数代替 html 模板渲染.
    render(createElement) {
        const create = (menuNode, createElement) => {
            let name = parent.name;

            // 根节点为数组, 直接创建 Menu 菜单包裹
            if(Array.isArray(menuNode)) {
                return createElement(
                    'Menu', 
                    {
                        props : {
                            activeName : this.activeName,
                            theme : this.theme,
                            width : 'auto',
                            openNames : this.openNames
                        },
                        on : {
                            'on-select' : this.select,
                            'on-open-change' : this.openChange
                        },
                        ref : 'menu'
                    },
                    [
                        createElement('template', {
                                slot : 'title'
                            },
                            menuNode.name
                        ),
                        ...menuNode.map(function(item) {
                            return create(item, createElement)
                        })
                    ]
                );
            }

            // 有子节点, 创建 SubMenu 节点
            if(Array.isArray(menuNode.children) && menuNode.children.length > 0) {
                return createElement(
                    'Submenu',
                    {
                        props : {
                            name : menuNode.id, // 设置 name
                        }
                    },
                    [
                        createElement('template', {
                                slot : 'title' //指定 title 插槽内容
                            },
                            [   
                                // 创建图标
                                createElement('Icon', {
                                    props : {
                                        type : 'ios-navigate',
                                    }
                                }),
                                // 设置菜单名称
                                menuNode.name
                            ]
                        ),
                        ...menuNode.children.map(function(item) {
                            return create(item, createElement)
                        })
                    ]
                )
            }

            // 创建 MenuItem 节点
            return createElement(
                'MenuItem',
                {
                    props : {
                        name : menuNode.id,
                    }
                },
                [
                    createElement('template', {
                            slot : 'default'
                        },
                        menuNode.name
                    ),
                ]
            );
        }

        return create(this.data, createElement);
    },

    watch : {
        openNames(newVal, oldVal) {
            this.$nextTick(function() {
                this.$refs.menu.updateOpened();
                this.$refs.menu.updateActiveName();
            });
        }
    },

    methods : {
        select : function(name) {
            this.$emit('on-select', name);
        },
        openChange(name) {
            this.$emit('on-open-change', name);
        }
    }
}

大家可能注意到了,我提供的菜单结构中是没有 id 属性,上面的渲染过程中咋会多了个 id 呢?这个 id 是在渲染树之前自动添加的,实现代码如下:

const eachStandardData(nodes, callback, pId, index) {
        if ((typeof callback) === 'function') {
            let newPId = val => {
                if(!val) return '1';
                let arr = val.split('-');
                arr.push((index + 1) + '');
                return arr.join('-');
            }

            if (Array.isArray(nodes) && nodes.length > 0) {
                let id = newPId(pId);

                nodes.forEach((node, index) => {
                    this.eachStandardData(node, callback, id, index);
                });

                return;
            }

            if ((typeof nodes) === 'object') {
                let id = newPId(pId);
                let flag = callback(id, nodes);

                if (Array.isArray(nodes.children) && nodes.children.length > 0 && flag) {
                    nodes.children.forEach((child, index) => {
                        this.eachStandardData(child, callback, id, index);
                    });
                }
            }
        }
    }

// menuTreeMetaData 就是需要渲染的菜单数据
let menuTreeMetaData =...;
let menuMap = new Map(); // 保存 id -> menuNode,点击按钮方便查询菜单节点信息。

eachStandardData(menuTreeMetaData, (id, node) => {
        node.id = id;
        menuMap.set(id, node);
        return true;
 });

执行 eachStandardData 之后菜单元数据就会变成下面的样子

const settingMenus = [
    {
        id : '1-1',
        name : 'foo1',
        children : [
            {id : '1-1-1', name : 'foo1-1', attrs : {router : '404'}},
            {id : '1-1-1', name : 'foo1-2', attrs : {router : '404'}}
        ]
    },
    { 
        id : '1-2',
        name : 'foo2',
        children : [
            {id: '1-2-1', name : 'foo2-1', attrs : {router : '404'}},
            {id: '1-2-2', name : 'foo2-1', attrs : {router : '404'}},
            {
                id : '1-2-3',
                name : 'foo2-2', 
                children : [
                    {id : '1-2-3-1', name : 'foo2-2-1', attrs : {router : '404'}},
                    {id : '1-2-3-2', name : 'foo2-3-1', attrs : {router : '404'}}
                ]
            }
        ]
    },
    ...
];

当然,为每个节点按照层级生成这种有规律的 id 不止是为了好看,主要还是为了下面的无关菜单自动收缩逻辑做准备,另外,大家可能注意到了,MenuTree.vue 组件(其实直接定义成一个 JS 文件也没问题,看个人习惯了),需要传入一些属性,如下:

  • data: 菜单节点数据,必须为一个数组。
  • activeName: 与 IView 中 Menu 组件的 activeName 一样,指示语法糖而已(语法糖都不算,算是又包裹了一层)。
  • theme: 菜单主题,概念同上。
  • openNames: 展开的菜单,数组,概念同上。

我们在使用 MenuTree 组件的时候,将我们上面准备好的:menuTreeMetaData (data属性), 'light'(theme),'1-1-1'(activeName) ,['1-1'](openNames) 传入 MenuTree 组件即可。

  • 聪明的小伙伴会注意到,上面我不是说 id 是为无关菜单自动收缩逻辑做准备吗?怎么现在似乎没看到呢!?其实大家会发现当我们知道了 activeName 就等于知道了父节点有哪些,那 openNames 应该是根据 activeName 计算的值,在 Vue 中将 openNames 定义为父组件就可以了,相关代码如下:
openNames() {
       let arr = this.selected.split('-');
       let openNames = [];

        for(var i = 2; i < arr.length; i++) {
            openNames.push(
                 this.menuMap.get(
                       arr.slice(0, i)
                            .join('-')
                  ).id
             );
         }
        return openNames;
 }
// 当 MenuTree 触发 on-select 事件的时候直接将选择的节点 id 赋值给 acitveName 即可,
// 这样子组件无关菜单就会相应收缩了。其实 openNames 放在 MenuTree 内部即可,
// 不用外部传递更方便,我这个写法有点脱裤子放屁的感觉了。

上面的语法使用了 ES6 的一些特性,直接粘贴在浏览器中不能使用哦。

5、如何使用?

  • 小伙伴们看来上面的代码可能有点懵,怎么使用呢?下面我简单的组合使用一下,假设您已经准备好如下三个文件了:
  • menus.js(文件中已近做了 id 自动生成处理)
const menuTreeMetaData = {};
export menuTreeMetaData;
  • CMenuTree.js(其实是 CMenuTree.vue, 这里我换用 js 方式直接使用)
export default {...}
  • Index.vue(使用位置)
// template 部分
<CMenuTree :data="menuData" :activeName="selected" :openNames="openNames" @on-select="menuSelect"></CMenuTree>

// script 部分
import Menu from './menus'
import CMenuTree from './CMenuTree'

export default {
    components : {
        CMenuTree
    },
    data() {
            return {
                menuMap,
                menuData : menus.settingMenus,
                selected : '1-5-2',
            }
        },

        mounted() {
        },

        computed : {
            openNames() {
                let arr = this.selected.split('-');
                let openNames = [];

                for(var i = 2; i < arr.length; i++) {
                    openNames.push(
                        this.menuMap.get(
                            arr.slice(0, i)
                                .join('-')
                        ).id
                    );
                }

                return openNames;
            }
        },
        methods: {
            menuSelect(name) {
                this.selected = name;
            }
        }
}

ok, 打完收功夫,怎么感觉比广大网友的复杂一些呢!?其实这种事情个人觉得还是仁者见仁智者见智吧!不过也算不同的解决方案了,这里和大家分享一下,如有问题请评论指出。

6、总结

经过这个例子实际体验 render 函数的强大,不过建议日常使用就别用这个 render 函数了,毕竟这个写着太繁琐了,正常情况直接用模板就可以了,简单方便。

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