爆肝长文~全网最全「树结构」相关实操

676 阅读9分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第 1 天,点击查看活动详情

背景

最近一直在开发一款低代码平台,遇到了「树结构」的处理。网上搜索了一下此类数据的处理方式,大多都是些面试题,红黑树、二叉树之类的(其实我只想 CTRL+C,再 CTRL+V), 又或者就单独拎出来讲解如何遍历,然后,就没然后了。没能找到一个比较全面且有实际案例的文章,本文通过一个实际案例,实现一个关于「树」的业务,包含了大部分通用问题的解法

案例

低代码平台一般分为3部分,画布、物料选择面板、属性编辑面板。属性面板又是通过一个一个的设置器堆叠而成,每个设置器对应一个物料属性。如下图所示:

image.png

我们以属性编辑面板的实现来总结树结构的操作

实现属性编辑面板

属性面板的作用,是在选中画布中的物料后,通过编辑属性面板中的值,来改变物料的值或者样式,是作为物料属性和用户交互的重要途径。

事先根据物料的 props 信息,定义好一份 meta 数据(树结构),然后通过解析这份 meta 数据,生成一份可供渲染的渲染数据,通过渲染数据可以将属性编辑面板渲染出来。当我们通过面板中的设置器修改属性值之后,需要同步修改渲染数据,来驱动属性编辑面板的 UI 更新

  • 技术需求
graph TD
meta -->|解析| 渲染数据
渲染数据 -->|渲染| 属性面板
属性面板 -->|更新| 渲染数据
  • 事先定义好的 meta 数据长这样:
/* meta 数据(树结构)
 * 这份数据描述了一个物料,接收2个属性:title 和 items,其中 items 是个对象数组
 * props: { 
             title, 
             items: [ { image } ] 
           }
 */

{
    ...
    props: [
        {
            name: 'title',
            title: '标题',
            setter: {
                type: 'StringSetter', // 设置器类型,用来确定渲染哪种设置器
            }
        },
        {
            name: 'items',
            title: '卡片列表',
            setter: {
                type: 'ArraySetter',
            },
            children: [
                {
                    name: 'image',
                    title: '图片',
                    setter: {
                        type: 'ImageSetter',
                    }
                },
                ...
            ]
        },
        ...
    ]
}

需求一: 在树节点上插入值

由于我们的 meta 数据中,只是记录了属性结构,我们需要在解析 meta 数据的时候,将属性的默认值插入进去。就像这样:

无标题-2022-12-11-1925.png

通常的做法,就是在遍历 meta.props 的时候插入值。常用的三种遍历方式:递归遍历深度优先遍历广度优先遍历

  • 递归遍历
const recursive = (props) => {
    return props.map(node => {
        // 打印当前节点
        console.log(node);

        if (node.children) {
            return recursive(node.children);
        }

        return node;
    })
}
  • 深度优先遍历
const depthFirst = (meta) => {
    // 模拟栈,管理结点
    const stack = meta.props
  
    while (stack.length) {
      // 栈顶结点出栈
      const node = stack.shift();
      
      // 打印当前节点
      console.log(node);
  
      let subProps = node.children || [];

      // 子节点有值
      if (subProps?.length) {
        
        // 将候选顶点入栈,进行下一次循环
        stack.unshift(...subProps.flat());
      }
    }
  };
  • 广度优先遍历
const depthFirst = (meta) => {
    // 模拟栈,管理结点
    const stack = meta.props
  
    while (stack.length) {
      // 栈顶结点出栈
      const node = stack.shift();
      
      // 打印当前节点
      console.log(node);
  
      let subProps = node.children || [];

      // 子节点有值
      if (subProps?.length) {
        
        // 将候选顶点入栈,进行下一次循环
        stack.push(...subProps.flat());
      }
    }
  };

递归之后可以保持原有的树结构,而且符合常规思维,实现起来比较容易。深度和广度遍历,会导致数据扁平化

此处要注意的是,理论上,树结构是很方便我们渲染UI的,但是,这里有个但是,如果我们后续需要对数据做增、删、改、查的操作,树结构是十分不方便的,可能会涉及到一次又一次的遍历,性能损耗、逻辑不易扩展的问题会接踵而来。另外,如果数据非常大,递归会导致内存不够,出现栈溢出。综上,我建议选择深度优先遍历,或者广度优先遍历,UI 渲染的问题,我们可以想办法解决嘛。本案例中,我选择的是深度优先遍历,因为我需要在遍历的时候,拿到当前节点的父节点信息:

import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';

// const defaultProps = { title: '一级标题', items: [{ image: 'https://xxx' }] };

const depthFirst = (meta) => {
        // 模拟栈,管理结点
        const stack = meta.props;

        // 收集 渲染数据
        const propsMap = {};
      
        while (stack.length) {
          // 栈顶结点出栈
          const node = stack.shift();
          
          if (!node.propPath) {
            // 给节点插入唯一标识,作 key。便于后续的增删改查
            node.propPath = node.name;
            node.parent = 'root';
          }

          // 给节点插入默认值
          node.defaultValue = get(defaultProps, node.propPath);

          // 将节点扁平化
          propsMap[node.propPath] = node;
      
          let subProps = node.children || [];
    
          // 子节点有值
          if (subProps?.length) {
            // 数组类型的需要转换成二维数组
            if (node.setter.type === 'ArraySetter') {
                subProps = node.defaultValue.map((_, index) => {
                    // 确保每次拿到的 subProps 是新的
                    subProps = cloneDeep(subProps);
                    return subProps.map(child => ({ ...child, parent: node.propPath, propPath: `${node.propPath}.${index}.${child.name}` }));
                });
            } else {
                subProps.map(child => ({ ...child, parent: node.propPath, propPath: `${node.propPath}.${child.name}` }));
            }

            // 保存子节点信息
            node.childrenPaths = subProps.flat().map(child => child.propPath);
            
            // 将候选顶点入栈,进行下一次循环
            stack.unshift(...subProps.flat());
          }
        }

        return propsMap;
};

解析之后的值就变成了

解析之后-2022-12-11-1925.png

这样的数据结构,我们很容易完成更新操作:propsMap[key] = value (key: items.0.image,操作是不是非常方便)

如果选择了递归遍历,更新值的方法可以是:

// key: items.0.image  
// value: '我是待更新的值'
// stack: 树

key.split('.').reduce((o, k, i, _) => {
    const keys = Array.isArray(o) ? o.map(item => item.name) : []

    if (i === _.length - 1) { // 若遍历结束直接赋值
        const res = o.find(item => item.name === k)
        if (res) {
            res['defaultValue'] = value
        }
        return null
    } else if (keys.includes(k)) { // 若存在对应路径,则返回找到的对象,进行下一次遍历
        const res = o.find(item => item.name === k)
        // 若 i !== _.length - 1,则存在子节点
        return res.children || []
    } else { // 若不存在对应路径,则可能是数组的下标
        const isNumber = /^[0-9]{1,}$/.test(k)
        return isNumber ? o[parseInt(k)] : []
    }
}, stack)
UI 渲染
// react

const Root = ({ propsMap }) => {
    // 找出第一级的节点
    const paths = Object.keys(propsMap).filter(path => !path.includes('.'))
    
    return (
        <ol>
            {paths.map(id => (
                  <Tree
                    key={id}
                    id={id}
                    treeById={propsMap}
                  />
            ))}
        </ol>
    )
}

const Tree = ({ treeById, id }) => {
    const { title, childrenPaths } = treeById[id]

    return (
        <li>
              {title}
              {childrenPaths.length > 0 && (
                <ol>
                  {childrenPaths.map(childId => (
                    <Tree
                      key={childId}
                      id={childId}
                      treeById={treeById}
                    />
                  ))}
                </ol>
              )}
        </li>
    )
}

需求二:优化渲染性能

经历一系列的挫折和挑战之后,我们的终于可以把 UI 渲染出来了。但很容易发现,不管是 meta 数据,还是扁平化之后的数据,都是一个复杂数据,对象嵌套数组,数组又嵌套对象,这是一个可以无限嵌套的数据。对于此类数据所渲染的 UI 更新,我们要怎么操作,第一念头就是深拷贝。于是乎,你的代码里,充满了deepClone,来实现 UI 的更新,类似于:

const [propsMap, setPropsMap] = useState({})
...
const update = ({ value, key }) => {
    const dataSource = deepClone(dataSource)
    dataSource[key] = value
    
    setPropsMap(dataSource)
}

实际情况可能要比上面的代码复杂很多,处理「树结构」的数据,多多少少会遇到这样的问题,那解法是啥呢?

  • 优雅的方式

为了避免这种低效且繁琐的更新方式,我们引入数据不可变的方案(immer),关于是用immer还是immediate,大家可以参考# 为什么说 90% 的情况下,immer 完胜 immutable?,这里也给大家总结一下,不是上亿的项目,用immer就够了

大致的代码如下:

// 此案例结合实际情况,加入 Provider + Context 进行状态管理

import { useContext } from 'react'; 
import { useImmerReducer } from 'use-immer';
import { TasksContext, TasksDispatchContext } from '../context';
import set from 'lodash/set';

const Index = () => {
    // 赋默认值 propsMap
    const [context, dispatch] = useImmerReducer(propsMap);
    
    // 更新值
    const update = ({ key, value }) => {
        dispatch(draft => {
            set(draft, key, value);
        })
    }
    
    return (
        <TasksContext.Provider value={context}>
              <TasksDispatchContext.Provider value={dispatch}>
                  <Root />
              </TasksDispatchContext.Provider>
        </TasksContext.Provider>
    )
}

const Root = () => {
    const treeById = useContext(TasksContext);
    // 找出第一级的节点
    const paths = Object.keys(treeById).filter(path => !path.includes('.'))
    
    return (
        <ol>
            {paths.map(id => (
                  <Tree
                    key={id}
                    id={id}
                  />
            ))}
        </ol>
    )
}

const Tree = ({ id }) => {
    const treeById = useContext(TasksContext);
    const { title, childrenPaths } = treeById[id]

    return (
        <li>
              {title}
              {childrenPaths.length > 0 && (
                <ol>
                  {childrenPaths.map(childId => (
                    <Tree
                      key={childId}
                      id={childId}
                    />
                  ))}
                </ol>
              )}
        </li>
    )
}

需求三:在节点中插入更多的值

需求一中,我们将默认值(defaultValue)插入节点中,但是,需求是多变的,假如产品说,要给设置器加个显隐开关,我们就要把状态信息(condition)插入节点,产品又说,也要展示当前设置器是否必填,我们又要把必填(require)插入节点,产品不停的说,还要对默认数据做过滤,另外,再加个校验吧...,这会导致我们频繁在我们的遍历函数中添加代码,导致代码臃肿逻辑杂乱。这时,我建议通过中间件模式解决

  • 中间件实现
class Parse {
  // 中间件队列
  queue = [];

  /**
   * 收集中间件
   * @param task 中间件
   */
  use = (task) => {
    if (Array.isArray(task)) {
      this.queue?.push(...task);
    } else {
      if (task) {
        this.queue?.push(task);
      }
    }
  };

  /**
   * 中间件处理
   * @param queue 中间件队列
   * @param extra 操作的信息
   */
  middleware = ({ queue = this.queue, extra = {} }) => {
    let i = 0;

    const next = () => {
      const task = queue?.[i++];
      if (!task) {
        return;
      }

      task({ next, ...extra });
    };

    next();
  };
}

利用中间件模式,我们可以把每个功能点分割成独立的代码块,提高代码的可读性和可维护性,例如,我们的业务代码可以写成这样:

// handleDefaultValue.ts  插入默认值
import defaultProps from 'defaultProps.ts'
// ...
const handleDefaultValue = (node) => {
    node.defaultValue = get(defaultProps, node.propPath)
    // ...
    next()
}

// handleCondition.ts  插入设置器状态值
const handleCondition = (node) => {
    // ...
    node.condition = ...
    
    next()
}

// 更多的中间件 ...

结合我们需求一,最终的代码可以是:

// ...

// 加载中间件
import { handleDefaultValue, handleConditon, handleXss } from '../middleware/index.ts';

class Parse {
    // ...
    
    init = () => {
        this.use([
            handleDefaultValue, 
            handleConditon, 
            handleXss,
        ])
    }
   
    // 执行解析
    handleMeta = () => {
        depthFirst(meta, (node) => {
            this.middleware({ extra: node })
        })
    }
    // ...
}

// ...
const depthFirst = (meta, middleware) => {
    // 模拟栈,管理结点
    const stack = meta.props
    
    // 控制是否停止循环
    let isStop = false
  
    while (stack.length && !isStop) {
      // 栈顶结点出栈
      const node = stack.shift();
      
      // 执行中间件
      // 除了传入节点信息,还可以根据业务需求,传入其他参数
      isStop = middleware(node)
  
      let subProps = node.children || [];

      // 子节点有值
      if (subProps?.length) {
        
        // 将候选顶点入栈,进行下一次循环
        stack.unshift(...subProps.flat());
      }
    }
 };
  
 // ...

需求四:支持外部注入中间件

这时,一个易用可扩展性能好的属性面板需求,基本完成✌🏻。这时,技术老大过来说,以后我们的项目要开源,属性面板的功能单独打成一个 npm 包,需要支持在包外部注入中间件,以满足不同开发者定制自己的 meta 解析逻辑,咱也来实现一个,类似于外部插件的实现,暴露一个注册函数,外部使用者,可以通过注册函数,将自己的逻辑代码注册到全局的 Map中,当我们绑定解析中间件的时候,再读取 Map 中已经注册的函数,并绑定到中间件中去,从而实现外部注入的逻辑:

// register.ts 实现注册逻辑

const middlewareMap = new Map();

// type: 'set' | 'get'
export function registerMiddleware(type, func) {
  const whitelist = ['set', 'get'];
  if (!whitelist.includes(type) || typeof func !== 'function') {
    return;
  }

  let funcs = middlewareMap.get(type);
  if (Array.isArray(funcs)) {
    funcs.push(func);
  } else {
    funcs = [func];
  }

  middlewareMap.set(type, funcs);
}

// 获取对应的注册的中间件
export function getMiddleware(type) {
  return middlewareMap.get(type) || null;
}
// 获取整个注册的中间件
export function getMiddlewareMap() {
  return middlewareMap;
}
import { getMiddleware } from '../../register';

class Parse {
    // ...
    
    init = () => {
        this.use([
            handleDefaultValue, 
            handleConditon, 
            handleXss,
        ])
        
        // 绑定外部注册的中间件
        const set = getMiddleware('set');
        if (Array.isArray(set)) {
          this.use(set);
        }
    }
    
    // ...
}

外部使用的时候,可以这样写:

import { registerMiddleware } from '@xxx/register'

// ... 各种业务代码

// 注册中间件
registerMiddleware('set', (node) => {
    node.require = true
    // ...
})

大功告成

额外的工具

在需求实现过程中,还积累了树的各种操作方法,这也记录下来:

获取所有子孙节点

// path: 当前节点的 propPath,deep: 是否查找所有子孙
const descendants = (path, deep = false) => {
    function appendChildNode(path, descendants = [], depth = 0) {
        if (deep || (!deep && depth === 0)) {
            const node = propsMap[path]
            if (!node) {
                return descendants
            }

            descendants.push(...(node?.childrenPaths || []));

            node.childrenPaths?.forEach(child => {
                descendants = appendChildNode(child, descendants, depth + 1);
            })
        }

        return descendants
    }

    return appendChildNode(path)
}

获取所有祖先节点

// path: 当前节点的 propPath,deep: 是否查找所有祖先
const ancestors = (path, deep = false) => {
    function appendParentNode(path, ancestors = [], depth = 0) {
        if (deep || (!deep && depth === 0)) {
            const node = propsMap[path]
            if (!node) {
                return ancestors
            }

            ancestors.push(node.parent);

            if (node.parent !== 'root') {
                ancestors = appendParentNode(node.parent, ancestors, depth + 1);
            }
        }

        return ancestors
    }

    return appendParentNode(path)
}

结束语

本案例的实现,一开始是采用的 递归遍历,每次增、删、改、查也都是通过递归去实现(递归找到对应的 node,再更新值),在 meta 数据比较少,且业务逻辑不复杂时,基本没有性能问题,需求一中提到的三种遍历方法的性能差不多。但是不巧的是,随着业务的迭代,需求的增多,递归更新值的性能问题越来越严重,而且代码十分不好维护。为此,才有了这篇文章。

今年,你学废了吗???

点赞 + 收藏 = 学会

参考

React Docs

我被骂了,但我学会了如何构造高性能的树状结构

# 为什么说 90% 的情况下,immer 完胜 immutable?