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 函数了,毕竟这个写着太繁琐了,正常情况直接用模板就可以了,简单方便。