背景
源码地址:github.com/yilaikesi/u...
先讲一背景吧。当时的需求是要设计一套组件。需要跨 vue 和 react 和 原生 的支持(一些老系统可以直接用)。刚好我自己正在做一些webcomponent的组件库,于是跟leader商量后不用element-ui和ant的组件库而是自己重新写一个tree组件。文章会详细介绍一些造组件库轮子的技巧并且最后会给出演示demo。对了,这里虽然说了webcomponet,本篇文章不会涉及webcomponent,而会用面向对象的方式封装一个tree组件出来。另外再说一嘴,这个组件应该是我封装过最值得优化和思考的组件。那么让我们开始吧
目前我实现的的tree有如下功能
php
/**
* @des 树功能清单
* @feature1 动态async ✅
* @feature2 单选 | 多选 逻辑 ✅
* @feature3 搜索树 ✅
* @feature4 拖拽树 | disabled 自定义图标逻辑 ❌ (这部分要用webcomponent的slot做,这里不做演示)
*/
下方这是 实现的 示例gif
在这个组件编写之前,我们可以参考一下网上的树组件教程和我们常用的组件库并且分析一下他们的逻辑结构。
技术选型
dom结构
我们可以看到比较奇怪的一幕,就是个人和小型组件库的大部分逻辑与大型组件库的逻辑有着挺大的区别的。首先是在ui的逻辑上面,像是ant-design,element-ui,native之类star比较多的组件库,无一例外的选择了用 对ui的结构进行 扁平化处理,比如antd 的 dom结构是这样的.然后控制 空格会根据它内部的layer属性实现动态添加 ant-tree-indent
ini
<div class="ant-tree-treenode" draggable="false" aria-grabbed="false"></div>
<div class="ant-tree-treenode" draggable="false" aria-grabbed="false"></div>
<div class="ant-tree-treenode" draggable="false" aria-grabbed="false"></div>
而像是小型项目的树的dom结构,比如 tiny-wheels 和 hview-ui,都是用递归生成的,如下
ini
<div class="tree-node">
<div class="node-content">
<span class="node-title ">标题-5</span>
</div>
<div class="node-children">
<div class="tree-node">
<div class="node-content">
<span class="node-title ">标题-6</span>
</div>
</div>
</div>
</div>
相比之下,
- 用递归dom这种方式生成的最大的一个问题就是功能扩展的艰难和动画效果制作难度翻倍,假如你知道react的演进流程,那么你会知道,递归生成ui中途是很难中断渲染的,这就导致了我们无法做更加细腻的控制。你可以看到像tiny-wheel 不支持 动态添加节点。hview-ui则是在展开tree和 收缩的时候有着奇怪的抖动
节点处理
而在节点处理的问题上,他们也是有着很多区别,大型组件库基本上的节点处理就是动态删掉和动态添加节点。而小型组件库基本上在一开始的base都是一次将所有节点渲染完。
相比之下,
- 大型组件库这种对于各个节点的处理在小型数据的渲染上必然稍逊于小型组件库,但是如果遇到大数据的情况,小型组件库会一次性渲染全部数据,必然导致dom渲染遇到大问题。会出现页面卡死的情况
数据结构
这里我们用element-plus和tiny-wheel的tree分别做一个示例
tiny-wheel 简单粗暴,在使用者传入了数组后,根本没有做任何操作。
element-plus 则 有点意思。在他的 element-plus/packages/components/tree/src/tree-node.vue
有这么一行代码
ini
<el-tree-node
v-for="child in node.childNodes"
/>
哎呀他不是树状结构吗,怎么这里变成了 扁平化的结构了呢
我们可以看到 element-plus/packages/components/tree/src/model
中,我们全局搜索,childrenNodes。可以发现他在 初始化的时候在insertChild 这个方法中,对用户传入的数组做了扁平化处理。也就是下面这个方法
kotlin
if (typeof index === 'undefined' || index < 0) {
this.childNodes.push(child as Node)
} else {
this.childNodes.splice(index, 0, child as Node)
}
这个思路为我们后续的事件处理奠定了一个基调
事件处理
这一部分各个组件库基本上都是高度一致,都是遍历用户的传入参数进行递归。还是拿着element-plus举例
javascript
const traverse = function (node: Node): void {
const childNodes = node.childNodes
childNodes.forEach((child) => {
if (!child.isLeaf) {
child.setChecked(false, false)
}
traverse(child)
})
}
traverse(node)
我们可以思考一下这里能不能进行优化,例如我们在搜索树的时候,展开树的时候,难道我们要每一次都遍历当前渲染出来的全部树吗?显然这种方式并不是最佳的。因此,我们可以实现一个hash表,用这个选项的key作为节点的 唯一标识,这样我们就可以通过获取这个选项的key 用 O(1) 的事件复杂度快速组装和渲染他的树和子节点。
当然这里我们就可以分成两个部分。用 react 的 话来说,就是初次 commit(render) 和 更新 commit(render)。两者阶段做的事情不一样
-
初次渲染,我们要递归遍历用户传入的数组,提取出两个东西
- 1.扁平化的数组用来初次渲染dom
- 2.用户key制作的hash表(这里用map比较好,但是我这里用了object其实都是一样的)。
-
更新commit,我们就可以用用户传入的数组直接替代掉相应位置的数组
- 1.我们把用户的hash表 从object 的样子 变成 tree,并且 更新到全局
- 2.根据事件。如果是搜索树那么需要递归找到父节点,对这一串列表的东西做出操作。假如是动态添加节点的树,那么我们需要递归找到子节点。这样子我们就可以确实的优化事件
总结
- dom结构:扁平化dom
- 节点处理:动态添加和移除节点
- 数据结构:扁平化 + 维护一个总的树 和 一个 hash
- 事件处理:分成更新渲染和初次渲染
那么接下来我们就可以根据这些知识实现我们自己的tree组件了
源码地址:github.com/yilaikesi/u...
组件数据定义
用户传入的数据结构
typescript
interface OptionType {
element: HTMLElement;
data: Array<DataType>;
// 是否多选
multiple: boolean;
// 点击toggle触发事件
toggle: Function;
// 选择触发事件
select: Function;
}
tree内部的数据结构
typescript
interface DataType {
title: string;
// 是否展开
expand: boolean;
// 是否选中
selected: boolean;
// 用户需要传入一个 key来作为唯一的标识
key: string;
layer: number;
parent: string;
children?: Array<DataType>;
// 是否被搜索
searched: boolean
}
组件关键方法分析
初始化渲染
kotlin
/**
* @des step1:初次渲染 | 平级渲染
*/
initTree() {
// 遍历树数据并初始化每个节点
for (let index = 0; index < this.TreeData.length; index++) {
this.initTreeNode(this.TreeData[index], 0);
}
this.flatData(this.TreeData, "0");
}
/**
* @des step2:dfs 递归遍历事件 | 初始化 | 展开
* @param node 数据格式
* @param layer 当前层级
* @param UpdateDom 更新的 dom
* @param DeleteDom 删除的dom
* @returns
*/
initTreeNode(node: DataType, layer: number, UpdateDom?: HTMLElement | false,) {
// ... 省略删除dom 和 更新 dom 的事件
$node = this.CreateTreeNode(node, layer, true)
// 无子集
if (!node.children) {
this.$container.appendChild($node);
return $node
}
// 有子集
for (let i = 0; i < node.children.length; i++) {
this.initTreeNode(node.children[i], layer + 1);
}
}
/**
* @des step3 创造子节点
* @param node 目前的
* @param layer 目前的层数
* @des UpdateTitle 传入就是在更新中
* @returns
*/
CreateTreeNode(node: DataType, layer: number, UpdateTitle?: boolean) {
// ... 省略增加 dom 的事件
const $node = document.createElement("div");
$node.setAttribute("class", "tree-node");
}
可以看到就是简单的递归判断有没有子集并且绑定事件
更新渲染-用动态加载dom作为示例
kotlin
/**
* @feature1 动态async 数据
* TreePrefData 添加传入的东西(附加上parent,这个时候父的箭头需要扩展)。
* 然后调用mergeData重新组装数据
*/
asyncData(parent: string, data: Array<DataType>) {
if (!this.TreePrefData[parent]) {
return
}
for (let i = 0; i < data.length; i++) {
this.TreePrefData[data[i].key] = data[i]
}
// 给父组件箭头 和 显示子组件添加动画
if (!this.TreePrefData[parent].children) {
this.TreePrefData[parent].children = []
let dom = this.$container.querySelector(`[tree-id="${parent}"]`)! as HTMLElement
dom?.querySelector(`.node-arrow`)?.setAttribute("class", `node-arrow fa fa-angle-right open`);
// 判断是否多选
if (!this.options.multiple) {
for (let i in this.TreePrefData) {
this.TreePrefData[this.TreePrefData[i].key].selected = false
let dom = this.$container.querySelector(`[tree-id="${this.TreePrefData[i].key}"]`)! as HTMLElement
dom?.querySelector(".node-title")?.classList?.remove("selected")
}
this.mergeData()
}
}
this.flatData(data, parent)
this.TreePrefData[parent].children!.push(...data)
this.mergeData()
}
可以看到我们在动态 async 数据的时候,
- 我们需要 在维护了 this.TreePrefData 这个数组后,
- 对其进行遍历,然后再这之中对这玩意进行 dom的处理
- 最后通过mergedata进行合并。
对了这是mergedata 方法
typescript
/**
* @des 将数据重新合并
*/
mergeData() {
let res: Array<any> = []
for (let i in this.TreePrefData) {
res.push(this.TreePrefData[i])
}
let temp = ArrayToTree(JSON.parse(JSON.stringify(res)), 0)
// console.log("mergeData:",temp)
this.TreeData = temp
}
feature特性的增加-用搜索树作为示例
kotlin
/**
* @feature3 搜索树功能
* @des 获取所有父节点
*/
searchParentSwitch(ParentId: string, StopFlag: string) {
let parent: any = []
if (this.TreePrefData[ParentId].parent != StopFlag) {
parent.push(this.TreePrefData[ParentId])
parent = parent.concat(this.searchParentSwitch(this.TreePrefData[ParentId].parent, "0"))
} else {
parent.push(this.TreePrefData[ParentId])
}
return parent
}
/**
* @feature3 搜索树功能
* @des 获取所有子节点
*/
searchChildSwitch(childId: string) {
let child: any = []
console.log(childId,this.TreePrefData[childId])
if (this.TreePrefData[childId].children) {
child.push(this.TreePrefData[childId])
for(let i in this.TreePrefData[childId].children){
// @ts-ignore
child = child.concat(this.searchChildSwitch(this.TreePrefData[childId]["children"]![i].key))
}
} else {
child.push(this.TreePrefData[childId])
}
return child
}
/**
* @feature3 搜索树功能
* @des 搜索树主要逻辑
* @param str
*/
searchData(str: string) {
let res: any = []
for (let i in this.TreePrefData) {
this.TreePrefData[this.TreePrefData[i].key]
if (this.TreePrefData[i].key.includes(str)) {
res.push(this.TreePrefData[i])
}
}
res.forEach((v: any) => {
let parentArr = this.searchParentSwitch(v!.key, "0")
for (let i = parentArr.length - 1; i >= 1; i--) {
// 处理展开事件....
}
let dom = this.$container.querySelector(`[tree-id="${v.key}"]`)! as HTMLElement
this.TreePrefData[v.key].searched = true
this.mergeData()
})
}
可以看到我们在 搜索树 的时候,
- 我们也需要 维护 this.TreePrefData 这个数组后,
- 对其进行遍历,然后再这之中对这玩意进行 dom的处理
- 最后通过mergedata进行合并。
之后的 feature的添加就 随便大家自定义了。