打造一款适合自己的快速开发框架-前端篇之选择树组件设计与实现

2,411 阅读7分钟

前言

任何业务系统都可能会涉及到对树型类数据的管理,如菜单管理、组织机构管理等。而在对树型类数据进行管理的时候一般都需要选择父节点,虽然elementui也有树型组件,但是如果直接使用,要完成该功能,需要编写的代码量也还是不少,所以我们要想更方便的时候,就得需要在其基础上进行进一步的封装。

选择树组件设计

数据结构

数型组件一般都需要一定规范的数据结构。

如下效果:

├──	节点1
	├── 节点11
		└── 节点111
	└── 节点12
├──	节点2
	├── 节点21
	└── 节点22

其标准的数据结构:

[
    {
        "id": 1,
        "name": "节点1",
        "children": [
            {
                "id": 11,
                "name": "节点11",
                "children": [
                    {
                        "id": 111,
                		"name": "节点111",
                    }
                ]
            },
            {
                "id": 12,
                "name": "节点12"
            }
        ]
    },
    {
        "id": 2,
        "name": "节点2",
        "children": [
            {
                "id": 21,
                "name": "节点21"
            },
            {
                "id": 22,
                "name": "节点22"
            }
        ]
    }
]

数据库的存储一般结构为:

[
    { "id": 1, "parentId": 0, "name": "节点1" },
    { "id": 11, "parentId": 1, "name": "节点11" },
    { "id": 111, "parentId": 11, "name": "节点111" },
    { "id": 12, "parentId": 1, "name": "节点12" },
    { "id": 2, "parentId": 0, "name": "节点2" },
    { "id": 21, "parentId": 2, "name": "节点21" },
    { "id": 22, "parentId": 2, "name": "节点22" }
]

数据结构处理

elementui的树型组件是不支持id/parentId模式的,需要组装成children模式,所以直接使用数据库的列表数据是不能直接展示成树状结构的。这就需要对原始的数据进行转换,常见的转换方式有两种,其实就是由哪一端处理。

  1. 后端按照前端需要的数据结构返回
  2. 后端只返回原始数据,由前端自行转换成标准的树型结构

本文为了方便,采用的是前端转换的方式,其实不管是哪一端,都可以写成通用的方法,只是java这边写成通用方法没有js方便,所以本框架选择在前端进行该转换动作。

接口说明

接口还是通用的查询接口,区别在于入参需要把pageSize调大一点,以菜单为例

请求地址

{{api_base_url}}/sys/menu/list

数据类型

application/json

请求示例:

{
    "pageNum": 1,
    "pageSize": 10000
}

响应示例:

{
	"code": 0,
	"msg": "查询菜单成功",
	"data": {
		"pageNum": 1,
		"pageSize": 10000,
		"recordCount": 16,
		"totalPage": 1,
		"rows": [{
			"id": 1,
			"parentId": 0,
			"name": "系统设置",
			"sort": 10.0,
			"routeName": "sys",
			"icon": "sys",
			"isShow": 2,
			"createTime": "2020-06-25 21:05:01",
			"updateTime": "2020-06-25 21:05:03",
			"isDeleted": 1
		}, {
			"id": 2,
			"parentId": 1,
			"name": "菜单管理",
			"sort": 1.0,
			"routeName": "sys:menu:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:06:34",
			"updateTime": "2020-06-25 21:06:36",
			"isDeleted": 1
		}, {
			"id": 3,
			"parentId": 1,
			"name": "用户管理",
			"sort": 2.0,
			"routeName": "sys:user:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:07:05",
			"updateTime": "2020-06-25 21:07:09",
			"isDeleted": 1
		}, {
			"id": 4,
			"parentId": 1,
			"name": "角色管理",
			"sort": 3.0,
			"routeName": "sys:role:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:07:37",
			"updateTime": "2020-06-25 21:07:41",
			"isDeleted": 1
		}, {
			"id": 5,
			"parentId": 1,
			"name": "字典管理",
			"sort": 4.0,
			"routeName": "sys:dict:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:08:08",
			"updateTime": "2020-06-25 21:08:11",
			"isDeleted": 1
		}, {
			"id": 6,
			"parentId": 0,
			"name": "内容管理",
			"sort": 11.0,
			"routeName": "cms",
			"icon": "cms",
			"isShow": 2,
			"createTime": "2020-06-25 21:09:05",
			"updateTime": "2020-06-25 21:09:07",
			"isDeleted": 1
		}, {
			"id": 7,
			"parentId": 6,
			"name": "栏目管理",
			"sort": 1.0,
			"routeName": "sys:category:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:09:36",
			"updateTime": "2020-06-25 21:09:39",
			"isDeleted": 1
		}, {
			"id": 8,
			"parentId": 6,
			"name": "模型管理",
			"sort": 2.0,
			"routeName": "sys:model:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:10:23",
			"updateTime": "2020-06-25 21:10:25",
			"isDeleted": 1
		}, {
			"id": 9,
			"parentId": 6,
			"name": "文章管理",
			"sort": 3.0,
			"routeName": "sys:article:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:10:50",
			"updateTime": "2020-06-25 21:10:53",
			"isDeleted": 1
		}, {
			"id": 10,
			"parentId": 0,
			"name": "订单管理",
			"sort": 12.0,
			"routeName": "oms",
			"isShow": 2,
			"createTime": "2020-06-25 21:11:29",
			"updateTime": "2020-06-25 21:11:31",
			"isDeleted": 1
		}, {
			"id": 11,
			"parentId": 10,
			"name": "订单列表",
			"sort": 1.0,
			"routeName": "oms:order:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:11:55",
			"updateTime": "2020-06-25 21:11:57",
			"isDeleted": 1
		}, {
			"id": 12,
			"parentId": 10,
			"name": "订单设置",
			"sort": 2.0,
			"routeName": "oms:orderSetting:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:12:15",
			"updateTime": "2020-06-25 21:12:19",
			"isDeleted": 1
		}, {
			"id": 13,
			"parentId": 0,
			"name": "商品管理",
			"sort": 13.0,
			"routeName": "pms",
			"icon": "pms",
			"isShow": 2,
			"createTime": "2020-06-25 21:14:02",
			"updateTime": "2020-06-25 21:14:05",
			"isDeleted": 1
		}, {
			"id": 14,
			"parentId": 13,
			"name": "商品分类",
			"sort": 1.0,
			"routeName": "pms:productCategory:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:16:05",
			"updateTime": "2020-06-25 21:16:07",
			"isDeleted": 1
		}, {
			"id": 15,
			"parentId": 13,
			"name": "商品列表",
			"sort": 2.0,
			"routeName": "pms:product:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:16:36",
			"updateTime": "2020-06-25 21:16:39",
			"isDeleted": 1
		}, {
			"id": 16,
			"parentId": 13,
			"name": "品牌管理",
			"sort": 3.0,
			"routeName": "pms:brand:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:16:57",
			"updateTime": "2020-06-25 21:17:01",
			"isDeleted": 1
		}]
	}
}

组件参数说明

暂时定几个常用的参数,后续可能还会有追加

参数名 类型 默认值 说明
url String undefined 接口地址
isEdit Boolean false 是否编辑模式
value String, Number,Array undefined 绑定的值
multiple Boolean false 是否多选(预留)
size String medium 组件大小medium/small/mini
placeholder String 请选择 占位符
dialogTitle String 请选择 弹窗标题
dialogWidth String 30% 弹窗宽度
defaultExpandAll Boolean false 是否默认展开所有节点

开始编码

目录结构

├── src
	├──	components/m
		├──	SelectTree
			└── index.vue
	├── utils
		└── util.js
	├── views
		├──	dashboard
			└── index.vue
	└── main.js

文件详解

  • src/components/m/Select/index.vue

选择树组件

<template>
  <div class="m-select-tree">
    <el-input readonly :size="size" :placeholder="placeholder" v-model="mValue">
      <el-button slot="append" icon="el-icon-search" @click="openDialog"></el-button>
    </el-input>
    <el-dialog :title="dialogTitle" :visible.sync="isOpenDialog" :width="dialogWidth" append-to-body @close="handleCancel">
      <el-tree
        :props="defaultProps"
        :data="treeData"
        node-key="id"
        highlight-current
        :default-expand-all="defaultExpandAll"
        @current-change="handleCurrentChange"
        ref="tree">
      </el-tree>
      <div slot="footer" class="dialog-footer">
        <el-button type="primary" @click="handleSubmit">确 定</el-button>
        <el-button @click="handleCancel">取 消</el-button>
      </div>
    </el-dialog>
  </div>
</template>
<script>
import request from '@/utils/request'
export default {
  name: 'MSelectTree',
  props: {
    url: { // 接口地址
      type: String,
      default: undefined
    },
    isEdit: { // 是否编辑模式
      type: Boolean,
      default: false
    },
    // 绑定的值
    value: {
      type: [String, Number, Array],
      default: undefined
    },
    multiple: { // 是否多选
      type: Boolean,
      default: false
    },
    size: { // medium/small/mini
      type: String,
      default: 'medium'
    },
    placeholder: { //  占位符
      type: String,
      default: '请选择'
    },
    dialogTitle: { //  弹窗标题
      type: String,
      default: '请选择'
    },
    dialogWidth: { // 弹窗宽度
      type: String,
      default: '30%'
    },
    defaultExpandAll: { // 是否默认展开所有节点
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      mValue: '根节点', // 显示的文本值
      isOpenDialog: false, // 是否打开弹窗
      treeData: [], // 树型结构
      tableData: [], // 原始数据
      defaultProps: { // elementui树型组件默认属性配置
        children: 'children',
        label: 'name'
      }
    }
  },
  watch: {
    value(n, o) { // 监听父组件值变动,子组件也要变动
      if (o === undefined || o === 0) {
        this.refreshView()
      }
    }
  },
  created() {
    if (this.isEdit) {
      this.requestData()
    }
  },
  methods: {
    requestData() {
      if (this.treeData.length) {
        this.$nextTick(() => {
          // dom更新完成再设置当前选中项
          this.refreshView()
        })
        return
      }
      if (this.url) {
        request({
          url: this.url,
          method: 'post',
          data: {
            pageNum: 1,
            pageSize: 10000
          }
        }).then(res => {
          if (res.code === 0) {
            this.treeData = [
              {
                id: 0,
                name: '根节点',
                children: []
              },
              // 这里使用工具方法将id/parentId数据结构转成children结构
              ...this.$util.getTree(res.data.rows)
            ]
            this.$nextTick(() => {
              // dom更新完成再设置当前选中项
              this.refreshView()
            })
          }
        })
      }
    },
    openDialog() { // 打开弹出框
      this.isOpenDialog = true
      this.requestData()
    },
    handleSubmit() {
      this.isOpenDialog = false
    },
    handleCancel() {
      this.isOpenDialog = false
    },
    // 处理当前选中节点变化时触发的事件
    handleCurrentChange(data) {
      // 修改显示
      this.mValue = data.name
      // 子组件值变化要通过父组件
      this.$emit('input', data.id)
    },
    // 刷新页面元素
    refreshView() {
      if (this.$refs.tree) {
        if (this.value === undefined) {
          this.$refs.tree.setCurrentKey(0)
        } else {
          this.$refs.tree.setCurrentKey(this.value)
        }
      }
      if (this.isEdit) {
        var nodes = this.tableData.filter(item => {
          return item.id === this.value
        })
        if (nodes.length) {
          this.mValue = nodes[0].name
        }
      }
    }
  }
}
</script>
  • src/utils/util.js

工具类,树型结构处理。很久之前写的了,使用的还是递归,还没进行优化。

/**
 * 根据key复制对象
 * @param {}} src
 * @param {*} dest
 */
export const copy = function(src, dest) {
  const res = {}
  Object.keys(dest).forEach(key => {
    res[key] = src[key]
  })
  return res
}
/**
 * 获取菜单树
 * @param {} nodes id/parentId格式数据
 */
export const getTree = (nodes) => {
  var root = []
  for (var i = 0; i < nodes.length; i++) {
    if (Number(nodes[i]['parentId']) <= 0) {
      root.push(nodes[i])
    }
  }
  return buildTree(nodes, root)
}
/**
 * 构建菜单树
 * @param {*} nodes id/parentId格式数据
 * @param {*} root 树节点
 */
export const buildTree = (nodes, root) => {
  for (var i = 0; i < root.length; i++) {
    root[i].title = root[i].name
    var children = []
    for (var k = 0; k < nodes.length; k++) {
      if (nodes[k]['parentId'] === root[i]['id']) {
        children.push(nodes[k])
      }
    }
    if (children.length !== 0) {
      root[i]['children'] = children
      buildTree(nodes, children)
    }
  }
  return root
}
/**
 * 先序遍历树
 * @param {*} tree 标准树结构
 * @param {*} level 层级
 */
export const preorder = (tree, level) => {
  var array = []
  for (var i = 0; i < tree.length; i++) {
    tree[i].level = level
    if (level === 1) {
      // tree[i].expand = true
    }
    if (tree[i]['children'] != null) {
      tree[i].leaf = false
      array.push(tree[i])
      array = array.concat(preorder(tree[i]['children'], level + 1))
    } else {
      tree[i].leaf = true
      array.push(tree[i])
    }
    tree[i]['children'] = []
  }
  return array
}
/**
 * 树型结构先序遍历转列表
 * @param {*} datas 标准树结构数据
 */
export const tranDataTreeToTable = (datas) => {
  return preorder(getTree(datas), 1)
}
export const getNode = (datas, id) => {
  const res = datas.filter(item => {
    return item.id === id
  })
  if (res.length) {
    return res[0]
  } else {
    return 0
  }
}
/**
 * 获取所有父级
 * @param {} datas
 * @param {*} id
 */
export const getParents = (datas, id) => {
  const res = []
  const node = getNode(datas, id)
  if (node) {
    res.push(node)
  }
  for (let i = 0, len = datas.length; i < len; i++) {
    const item = datas[i]
    if (item.id === node.parentId) {
      res.push(item)
      res.push(...getParents(datas, item.id))
      break
    }
  }
  return res
}
/**
 * 获取所有子元素
 * @param {*} datas
 * @param {*} id
 * @param {*} containParent 是否包含父id
 */
export const getChildren = (datas, id, containParent) => {
  const res = []
  if (containParent === undefined) {
    containParent = true
  }
  const node = getNode(datas, id)
  if (node) {
    if (containParent) {
      res.push(node)
    }
  } else {
    return res
  }
  for (let i = 0, len = datas.length; i < len; i++) {
    const item = datas[i]
    if (item.parentId === id) {
      res.push(item)
      res.push(...getChildren(datas, item.id, false))
    }
  }
  return res
}
  • src/main.js

主入口全局注册自定义组件,这里也用了require.context,代码片段,这里简单的对驼峰进行了-转换

// 处理自定义组件全局注册
const files = require.context('./components/m', true, /\.vue$/)
files.keys().forEach((routerPath) => {
  const componentName = routerPath.replace(/^\.\/(.*)\/index\.\w+$/, '$1')
  const value = files(routerPath)
  Vue.component('m' + componentName.replace(/([A-Z])/g, '-$1').toLowerCase(), value.default)
}, {})
  • src/views/dashboard/index.vue

这里提供了使用样例:

选择单个

<m-select-tree dialog-title="请选择父菜单" v-model="parentId" url="sys/menu/list" value-key="id" label-key="name"></m-select-tree>

选择单个-修改模式

<m-select-tree dialog-title="请选择父菜单" v-model="form.parentId" is-edit url="sys/menu/list" value-key="id" label-key="name"></m-select-tree>

js片段

export default {
  name: 'Dashboard',
  data() {
    return {
      form: {
        parentId: undefined
      },
      parentId: undefined
    }
  },
  created() {
    // 模拟修改异步更新
    setTimeout(() => {
      this.$set(this.form, 'parentId', 1)
    }, 2000)
  }
}

效果图

小结

本文的选择树组件使用了elementui的三个组件(Input/Dialog/Tree)进行组装,目前只做了单选的,如后续场景需要再考虑支持多选。

项目源码地址

  • 后端

gitee.com/mldong/mldo…

  • 前端

gitee.com/mldong/mldo…

相关文章

打造一款适合自己的快速开发框架-先导篇

打造一款适合自己的快速开发框架-前端脚手架搭建

打造一款适合自己的快速开发框架-前端篇之登录与路由模块化

打造一款适合自己的快速开发框架-前端篇之框架分层及CURD样例

打造一款适合自己的快速开发框架-前端篇之字典组件设计与实现

打造一款适合自己的快速开发框架-前端篇之下拉组件设计与实现