JavaScript 处理树结构数据
场景
即便在前端,也有很多时候需要操作 树结构 的情况,最典型的场景莫过于 无限级分类。之前吾辈曾经遇到过这种场景,但当时没有多想直接手撕 JavaScript 列表转树了,并没有想到进行封装。后来遇到的场景多了,想到如何封装树结构操作,但考虑到不同场景的树节点结构的不同,就没有继续进行下去了。
直到吾辈开始经常运用了 ES6 Proxy 之后,吾辈想到了新的解决方案!
思考
问: 之前为什么停止封装树结构操作了?
答: 因为不同的树结构节点可能有不同的结构,例如某个项目的树节点父节点 id 字段是 parent,而另一个项目则是 parentId
问: Proxy 如何解决这个问题呢?
答: Proxy 可以拦截对象的操作,当访问对象不存在的字段时,Proxy 能将之代理到已经存在的字段上
问: 这点意味着什么?
答: 它意味着 Proxy 能够抹平不同的树节点结构之间的差异!
问: 我还是不太明白 Proxy 怎么用,能举个具体的例子么?
答: 当然可以,我现在就让你看看 Proxy 的能力
下面思考一下如何在同一个函数中处理这两种树节点结构
/** * 系统菜单 */ class SysMenu { /** * 构造函数 * @param {Number} id 菜单 id * @param {String} name 显示的名称 * @param {Number} parent 父级菜单 id */ constructor(id, name, parent) { this.id = id this.name = name this.parent = parent } } /** * 系统权限 */ class SysPermission { /** * 构造函数 * @param {String} uid 系统唯一 uuid * @param {String} label 显示的菜单名 * @param {String} parentId 父级权限 uid */ constructor(uid, label, parentId) { this.uid = uid this.label = label this.parentId = parentId } }
下面让我们使用 Proxy 来抹平访问它们之间的差异
const sysMenuMap = new Map().set('parentId', 'parent') const sysMenu = new Proxy(new SysMenu(1, 'rx', 0), { get(_, k) { if (sysMenuMap.has(k)) { return Reflect.get(_, sysMenuMap.get(k)) } return Reflect.get(_, k) }, }) console.log(sysMenu.id, sysMenu.name, sysMenu.parentId) // 1 'rx' 0 const sysPermissionMap = new Map().set('id', 'uid').set('name', 'label') const sysPermission = new Proxy(new SysPermission(1, 'rx', 0), { get(_, k) { if (sysPermissionMap.has(k)) { return Reflect.get(_, sysPermissionMap.get(k)) } return Reflect.get(_, k) }, }) console.log(sysPermission.id, sysPermission.name, sysPermission.parentId) // 1 'rx' 0
定义桥接函数
现在,差异确实抹平了,我们可以通过访问相同的属性来获取到不同结构对象的值!然而,每个对象都写一次代理终究有点麻烦,所以我们实现一个通用函数用以包装。
/** * 桥接对象不存在的字段 * @param {Object} map 代理的字段映射 Map * @returns {Function} 转换一个对象为代理对象 */ export function bridge(map) { /** * 为对象添加代理的函数 * @param {Object} obj 任何对象 * @returns {Proxy} 代理后的对象 */ return function(obj) { return new Proxy(obj, { get(target, k) { if (Reflect.has(map, k)) { return Reflect.get(target, Reflect.get(map, k)) } return Reflect.get(target, k) }, set(target, k, v) { if (Reflect.has(map, k)) { Reflect.set(target, Reflect.get(map, k), v) return true } Reflect.set(target, k, v) return true }, }) } }
现在,我们可以用更简单的方式来做代理了。
const sysMenu = bridge({ parentId: 'parent', })(new SysMenu(1, 'rx', 0)) console.log(sysMenu.id, sysMenu.name, sysMenu.parentId) // 1 'rx' 0 const sysPermission = bridge({ id: 'uid', name: 'label', })(new SysPermission(1, 'rx', 0)) console.log(sysPermission.id, sysPermission.name, sysPermission.parentId) // 1 'rx' 0
定义标准树结构
想要抹平差异,我们至少还需要一个标准的树结构,告诉别人我们需要什么样的树节点数据结构,以便于在之后处理树节点的函数中统一使用。
/** * 基本的 Node 节点结构定义接口 * @interface */ export class INode { /** * 构造函数 * @param {Object} [options] 可选项参数 * @param {String} [options.id] 树结点的 id 属性名 * @param {String} [options.parentId] 树结点的父节点 id 属性名 * @param {String} [options.child] 树结点的子节点数组属性名 * @param {String} [options.path] 树结点的全路径属性名 * @param {Array.<Object>} [options.args] 其他参数 */ constructor({ id, parentId, child, path, ...args } = {}) { /** * @field 树结点的 id 属性名 */ this.id = id /** * @field 树结点的父节点 id 属性名 */ this.parentId = parentId /** * @field 树结点的子节点数组属性名 */ this.child = child /** * @field 树结点的全路径属性名 */ this.path = path Object.assign(this, args) } }
实现列表转树
列表转树,除了递归之外,也可以使用循环实现,这里便以循环为示例。
思路
- 在外层遍历子节点
- 如果是根节点,就添加到根节点中并不在找其父节点。
- 否则在内层循环中找该节点的父节点,找到之后将子节点追加到父节点的子节点列表中,然后结束本次内层循环。
/** * 将列表转换为树节点 * 注:该函数默认树的根节点只有一个,如果有多个,则返回一个数组 * @param {Array.<Object>} list 树节点列表 * @param {Object} [options] 其他选项 * @param {Function} [options.isRoot] 判断节点是否为根节点。默认根节点的父节点为空 * @param {Function} [options.bridge=returnItself] 桥接函数,默认返回自身 * @returns {Object|Array.<String>} 树节点,或是树节点列表 */ export function listToTree( list, { isRoot = node => !node.parentId, bridge = returnItself } = {}, ) { const res = list.reduce((root, _sub) => { if (isRoot(sub)) { root.push(sub) return root } const sub = bridge(_sub) for (let _parent of list) { const parent = bridge(_parent) if (sub.parentId === parent.id) { parent.child = parent.child || [] parent.child.push(sub) return root } } return root }, []) // 根据顶级节点的数量决定如何返回 const len = res.length if (len === 0) return {} if (len === 1) return res[0] return res }
抽取通用的树结构遍历逻辑
首先,明确一点,树结构的完全遍历是通用的,大致实现基本如下
- 遍历顶级树节点
- 遍历树节点的子节点列表
- 递归调用函数并传入子节点
/** * 返回第一个参数的函数 * 注:一般可以当作返回参数自身的函数,如果你只关注第一个参数的话 * @param {Object} obj 任何对象 * @returns {Object} 传入的第一个参数 */ export function returnItself(obj) { return obj } /** * 遍历并映射一棵树的每个节点 * @param {Object} root 树节点 * @param {Object} [options] 其他选项 * @param {Function} [options.before=returnItself] 遍历子节点之前的操作。默认返回自身 * @param {Function} [options.after=returnItself] 遍历子节点之后的操作。默认返回自身 * @param {Function} [options.paramFn=(node, args) => []] 递归的参数生成函数。默认返回一个空数组 * @returns {INode} 递归遍历后的树节点 */ export function treeMapping( root, { before = returnItself, after = returnItself, paramFn = (node, ...args) => [], } = {}, ) { /** * 遍历一颗完整的树 * @param {INode} node 要遍历的树节点 * @param {...Object} [args] 每次递归遍历时的参数 */ function _treeMapping(node, ...args) { // 之前的操作 let _node = before(node, ...args) const childs = _node.child if (arrayValidator.isEmpty(childs)) { return _node } // 产生一个参数 const len = childs.length for (let i = 0; i < len; i++) { childs[i] = _treeMapping(childs[i], ...paramFn(_node, ...args)) } // 之后的操作 return after(_node, ...args) } return _treeMapping(root) }
使用 treeMapping 遍历树并打印
const tree = { uid: 1, childrens: [ { uid: 2, parent: 1, childrens: [{ uid: 3, parent: 2 }, { uid: 4, parent: 2 }], }, { uid: 5, parent: 1, childrens: [{ uid: 6, parent: 5 }, { uid: 7, parent: 5 }], }, ], } // 桥接函数 const bridge = bridge({ id: 'uid', parentId: 'parent', child: 'childrens', }) treeMapping(tree, { // 进行桥接抹平差异 before: bridge, // 之后打印每一个 after(node) { console.log(node) }, })
实现树转列表
当然,我们亦可使用 treeMapping 简单的实现 treeToList,当然,这里考虑了是否计算全路径,毕竟还是要考虑性能的!
/** * 将树节点转为树节点列表 * @param {Object} root 树节点 * @param {Object} [options] 其他选项 * @param {Boolean} [options.calcPath=false] 是否计算节点全路径,默认为 false * @param {Function} [options.bridge=returnItself] 桥接函数,默认返回自身 * @returns {Array.<Object>} 树节点列表 */ export function treeToList( root, { calcPath = false, bridge = returnItself } = {}, ) { const res = [] treeMapping(root, { before(_node, parentPath) { const node = bridge(_node) // 是否计算全路径 if (calcPath) { node.path = (parentPath "htmlcode">const tree = { uid: 1, childrens: [ { uid: 2, parent: 1, childrens: [{ uid: 3, parent: 2 }, { uid: 4, parent: 2 }], }, { uid: 5, parent: 1, childrens: [{ uid: 6, parent: 5 }, { uid: 7, parent: 5 }], }, ], } const fn = bridge({ id: 'uid', parentId: 'parent', child: 'childrens', }) const list = treeToList(tree, { bridge: fn, }) console.log(list)总结
那么,JavaScript 中处理树结构数据就到这里了。当然,树结构数据还有其他的更多操作尚未实现,例如常见的查询子节点列表,节点过滤,最短路径查找等等。但目前列表与树的转换才是最常用的,而且其他操作基本上也是基于它们做的,所以这里也便点到为止了。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。
免责声明:本站资源来自互联网收集,仅供用于学习和交流,请遵循相关法律法规,本站一切资源不代表本站立场,如有侵权、后门、不妥请联系本站删除!
稳了!魔兽国服回归的3条重磅消息!官宣时间再确认!
昨天有一位朋友在大神群里分享,自己亚服账号被封号之后居然弹出了国服的封号信息对话框。
这里面让他访问的是一个国服的战网网址,com.cn和后面的zh都非常明白地表明这就是国服战网。
而他在复制这个网址并且进行登录之后,确实是网易的网址,也就是我们熟悉的停服之后国服发布的暴雪游戏产品运营到期开放退款的说明。这是一件比较奇怪的事情,因为以前都没有出现这样的情况,现在突然提示跳转到国服战网的网址,是不是说明了简体中文客户端已经开始进行更新了呢?
更新日志
- 凤飞飞《我们的主题曲》飞跃制作[正版原抓WAV+CUE]
- 刘嘉亮《亮情歌2》[WAV+CUE][1G]
- 红馆40·谭咏麟《歌者恋歌浓情30年演唱会》3CD[低速原抓WAV+CUE][1.8G]
- 刘纬武《睡眠宝宝竖琴童谣 吉卜力工作室 白噪音安抚》[320K/MP3][193.25MB]
- 【轻音乐】曼托凡尼乐团《精选辑》2CD.1998[FLAC+CUE整轨]
- 邝美云《心中有爱》1989年香港DMIJP版1MTO东芝首版[WAV+CUE]
- 群星《情叹-发烧女声DSD》天籁女声发烧碟[WAV+CUE]
- 刘纬武《睡眠宝宝竖琴童谣 吉卜力工作室 白噪音安抚》[FLAC/分轨][748.03MB]
- 理想混蛋《Origin Sessions》[320K/MP3][37.47MB]
- 公馆青少年《我其实一点都不酷》[320K/MP3][78.78MB]
- 群星《情叹-发烧男声DSD》最值得珍藏的完美男声[WAV+CUE]
- 群星《国韵飘香·贵妃醉酒HQCD黑胶王》2CD[WAV]
- 卫兰《DAUGHTER》【低速原抓WAV+CUE】
- 公馆青少年《我其实一点都不酷》[FLAC/分轨][398.22MB]
- ZWEI《迟暮的花 (Explicit)》[320K/MP3][57.16MB]