背景概述
最近在做一个商业化系统的权限模块,涉及到很多树相关的组件,比如组织架构图,部门选择,权限树的编辑等等。但是由于项目的原因,暂时不能使用市面上类似antDesign等开源的组件库,项目内部的组件库中树组件又满足不了业务需求(没有展示线)。所以需要重新写一个树组件来满足业务开发需求。
按照现在前端流行的框架比如React、Vue等开发方式,我们写组件都是逻辑夹杂着视图写在一起。这次受到 js-tree 、react-ui-tree 与项目中用到的 ten-design-react中树组件的启发,决定采用相同的思想来写一个树组件。本文接下来的内容将讲述如何将逻辑从组件中抽离出来,不受视图印象,并从零开发一个简单的React、Vue版本的树组件。
本文写作目的主要是记录一个组件如何从零开始构思并一步一步写起来,从思想到具体实现,所以会有大量代码,比较枯燥。
数据结构
开发者平时接触的最多的树结构应该就是编辑器,比如vsCode中的项目目录,或者平时调试时的变量树。
这里我们先假设树只有最简单的展示与展开收起功能,由上面的树结构我们可以简单的推导出来树节点的数据结构如下,每个节点负责当前节点上面的属性及节点之间的关系。
1 2 3 4 5 6 7 8 9 10 11 12 13
| abstract class TreeNode<T> { value: ID; label: string; dataset: T; parent?: TreeNode<T>; children: TreeNode<T>[]; isFirst: boolean; isLast: boolean; isLeaf: boolean; visible: boolean; expanded: boolean; }
|
假设组件可以根据TreeNode去渲染对应的视图,但是如何对树的所有节点进行管理,并且节点之间的全局信息又是从哪里获得?比如当前树中有哪些节点是已展开的,这些信息我们不可能每次想知道都重新遍历生成一次,所以我们需要有一个管理这些节点的对象,称之为 TreeStore。
TreeStore负责当前整棵树的节点管理,包括节点获取,配置管理以及树的全局信息存储等,树的属性大概是这样的。
1 2 3 4 5 6 7 8 9
| abstract class TreeStore<T> { data: T[]; nodeMap: Map<ID, TreeNode<T>>; nodes: TreeNode<T>[]; children: TreeNode<T>[]; config: TreeStoreOptions<T>; expandedSet: Set<ID>; }
|
构建树结构并初始化节点属性
明确完数据结构之后,可以开始编写代码
首先定义好 append 负责将传入的数组对象构建成一个数,并添加到 children中。
1 2 3 4
| append(): void { this.children = data.map(item => new TreeNode<T>(this, item)) }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| constructor(tree: TreeStore<T>, data: T, parent: TreeNode<T> | null = null) {
this.tree = tree this.dataset = data this.label = this.getLabel() this.value = this.getValue() if (parent) this.parent = parent this.tree.nodeMap.set(this.value, this)
this.initExpanded() this.initVisible() if (this.dataset.children) { this.children = this.dataset.children.map(child => new TreeNode<T>(child, tree, this)) } }
initExpanded(): boolean { }
initVisible(): boolean { }
|
通过上述初始化过程,我们其实已经得到一颗非常基础的树了,至少是一个包含树结构的对象。
但是怎么获取树上面的节点,或者怎么知道需要渲染那些节点呢?这时候就需要给treeStore添加一些方法来获取树节点了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| append(): void { this.nodes = this.refreshNodes() }
refreshNodes(): TreeNode<T>[] { this.nodes.length = 0 this.children.forEach(child => { if (this.nodeMap.has(child.value)) { this.nodes.push( ...child.walk() ) } }) return this.nodes }
getNodes(): TreeNode<T>[] { return this.nodes; }
|
1 2 3 4 5 6 7 8 9
|
walk(includeSelf: boolean = true): TreeNode<T>[] { const nodes: TreeNode<T>[] = includeSelf? [this]: [] this.children.forEach(child => { nodes.push(...child.walk()) }) return nodes }
|
这样我们就可以在组件中先实例化treeStore,再调用getNodes来获取需要渲染的节点模型数据。
1 2 3 4 5
| const store = new TreeStore<T>({ data: [{value, label, children}] }) const nodes = store.getNodes() const visibleNodes = nodes.filter(node => node.visible)
render(visibleNodes)
|
触发视图更新
现在树模型的基本架子已经搭好了,有了基本属性,也能获取当前类型用于渲染的节点,但是如果这时候树节点需要更新状态,比如展开,收起,要如何更新并且通知到组件重新渲染。
所以我们要解决的有两件事,第一如何更新,第二如何通知组件重新渲染。
如何更新
举展开收起为例子,更新expanded这个状态一般有两种情况。第一种情况是单个节点的更新,一般是用户点击操作触发某个结点的展开收起状态变更。第二种情况是批量更新,一般是初始化时想要展开多个节点,或者受控情况下每次有变动就主动触发一次树节点的批量变化。
对于第一种情况,我们可以在treeNode上添加一个方法来处理节点的更新,并且更新store中的expandedSet集合。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| setExpanded(expanded: boolean): void { const { tree: { expandedSet }, value } = this this.expanded = expanded if (expanded) { expandedSet.add(value) } else { expandedSet.delete(value) } this.updateVisible() }
updateVisible(): void { this.visible = this.getVisible() }
updateExpanded(): void { this.expanded = this.getExpanded() }
getParents(): TreeNode<T>[] { const parents: TreeNode<T>[] = [] let parent = this.parent while (parent) { parents.push(parent) parent = parent.parent } return parents }
|
第二种情况,我们可以在store上添加一个方法,用于替换当前树中的expandedSet集合,在这个方法里面我们先比对当前与目标对象的差异,对expandedSet做对应的修改,并且调用相关的节点更新expanded与visible状态
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| replaceExpanded(expandedList: ID[]): void { const currentList = Array.from(this.expandedSet) const nextList = list const remove = difference(currentList, nextList) const add = difference(nextList, currentList) remove.forEach(id => { this.expandedSet.delete(id) }) add.forEach(id => { this.expandedSet.add(id) }); [...remove, ...add].forEach(updateId => { const node = this.getNode(updateId) if (!node) return node.updateExpanded() })
const relatedNodes = this.getRelatedNodes([...remove, ...add].map(v => this.getNode(v))) relatedNodes.forEach(node => node.updateVisible()) }
getRelatedNodes(list: TreeNode<T>[], withParents: boolean = true): TreeNode<T>[] { const nodes: TreeNode<T>[] = [] const set: Set<TreeNode<T>> = new Set() list.forEach(node => { if (set.has(node) || !this.nodeMap.has(node.value)) return; set.add(node) nodes.push(...node.walk()) if (withParents) nodes.push(...node.getParents()) }) return nodes; }
|
如何通知视图更新
到这里我们的所有方法都仅限于数据模型的逻辑,并没有涉及到视图相关的部分。那么要如何在需要更新是触发视图的更新呢?
我们可以在实例化store时传入一个回调,当树中有更新是,调用该回调触发视图的更新。所以问题点转为,在节点一些属性变化时,我们需要触发视图的更新。
这里可能会发现一些问题,expanded变动时我们需要触发更新,visible变动我们也需要触发视图更新,这样的话当前代码存在两个问题,第一个是updateXXX太过分散,每个updateXX中去触发也不是不可以,但是这样很麻烦。第二个问题是如果每个节点update的时候都触发一次回调,那频率太过频繁了,会造成很多性能问题。
基于上述两个问题我们可以分别这么解决,统一一个update方法,在update方法中更新需要更新的visible、expanded属性。在store中实现一个throttle,一段时间才去触发一次更新,这里可以简单的用setTimeout去解决。
treeNode添加更新函数,并触发tree的更新操作
1 2 3 4 5 6 7
| update(): void { this.visible = this.getVisible() this.expanded = this.getExpanded() this.tree.update(this) }
|
treeStore维护一个更新函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| class TreeStore ... { updatedSet: Set<ID> = new Set() updateTimer: number update(node?: TreeNode<T>): void { const { updateSet } = this node && this.updateSet.add(node.value); if (this.updateTimer) return this.updateTimer = setTimeout(() => { this.updateTimer = null const { config: { onUpdate } } = this onUpdate?.({ nodes: Array.from(updateSet.keys()).map(id => this.getNode(id)) }) }) } }
|
组件内部
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
function getNodes(store: TreeStore<T>): TreeNode<T>[] { const nodes = store.getNodes() const visibleNodes = nodes.filter(node => node.visible) return visibleNodes }
const store = new TreeStore<T>({ data: [{value, label, children}], onUpdate() { const nodes = getNodes(store) reRender(nodes) } })
const visibleNodes = getNodes(store) render(visibleNodes)
|
React 组件
这里我们实现一个最简单基础的React树组件,包括树结构模型的生成以及展开收起逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| function ReactTreeComponent<T>(props) { const { data, } = props
const [visibleNode, updateVisibleNode] = React.useState<TreeNode<T>[]>([])
const store = React.useRef(new TreeStore<T>({ onUpdate() { const nodes = store.getNodes().filter(node => node.visible) updateVisibleNode(nodes) } })).current store.append(data)
return ( <> { visibleNode.map(node => { const { value, label, expanded, setExpanded, isLeaf } = node return ( <span key={value} > { isLeaf? null: <span onClick={() => setExpanded(!expanded)}> { expanded? '-': '+' } </span> } {label} </span> ) }) } </> ) }
|
Next
到这里,tree-store的思想已经描述完。但是目前的tree-store还不够完善,比如可以添加 select, checkbox属性,然后在对应的view实现里面实现多选和单选的功能。也可以添加 load、lazy 属性,负责children的加载以及加载方式。
当然,我们也可以扩展一下,比如前端用的很多的表单控件(组件)。是不是也可以抽象成一个数据模型和一个视图渲染层,数据模型负责每个字段的校验,取值,是否必填,数值转换以及label、placeholder等属性,视图渲染层只需要根据模型变动重新渲染即可。不过表单数据模型的话,很难用事件传递的方式(手动调用update去触发reRender),更适合用Proxy的方式,每个字段如果有变动,直接去调用,避免手动管理的麻烦。也可以扩展到table组件上,table组件有一个ReactTable的实现方式就比较相似,但是ReactTable也是局限于用React的Hook去实现一套数据管理,如果能把这一层更抽象,脱离React技术栈,那后面vue、angular也能直接拿来封装就用,达到复用的效果。