开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第 1 天,点击查看活动详情
背景
最近一直在开发一款低代码平台,遇到了「树结构」的处理。网上搜索了一下此类数据的处理方式,大多都是些面试题,红黑树、二叉树之类的(其实我只想 CTRL+C,再 CTRL+V), 又或者就单独拎出来讲解如何遍历,然后,就没然后了。没能找到一个比较全面且有实际案例的文章,本文通过一个实际案例,实现一个关于「树」的业务,包含了大部分通用问题的解法
案例
低代码平台一般分为3部分,画布、物料选择面板、属性编辑面板。属性面板又是通过一个一个的设置器堆叠而成,每个设置器对应一个物料属性。如下图所示:
我们以属性编辑面板的实现来总结树结构的操作
实现属性编辑面板
属性面板的作用,是在选中画布中的物料后,通过编辑属性面板中的值,来改变物料的值或者样式,是作为物料属性和用户交互的重要途径。
事先根据物料的 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 数据的时候,将属性的默认值插入进去。就像这样:
通常的做法,就是在遍历 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;
};
解析之后的值就变成了
这样的数据结构,我们很容易完成更新操作: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 数据比较少,且业务逻辑不复杂时,基本没有性能问题,需求一中提到的三种遍历方法的性能差不多。但是不巧的是,随着业务的迭代,需求的增多,递归更新值的性能问题越来越严重,而且代码十分不好维护。为此,才有了这篇文章。
今年,你学废了吗???
点赞 + 收藏 = 学会