Skip to content

Commit

Permalink
refactor: tree 组件改进列表渲染逻辑 (#2586)
Browse files Browse the repository at this point in the history
* test(tree): tree 组件,虚拟滚动调试示例添加过滤功能

* test(tree): tree 组件,虚拟滚动完善过滤示例

* docs(tree): tree 组件修正代码注释文案

* refactor(tree): tree 组件改进列表渲染逻辑

* fix(tree): tree 组件,解决结构改造后,非虚拟滚动模式未能缓存已创建节点的问题

* docs(tree): tree 组件,完善调试页面配置

* fix(tree): tree 组件完善示例,部分示例事件发生后,控制台输出节点状态

* test(tree): tree 组件,更新 test snapshot

* test(tree): tree 组件,update snapshot
  • Loading branch information
TabSpace authored Jul 23, 2023
1 parent a733d9e commit 55eb29a
Show file tree
Hide file tree
Showing 13 changed files with 8,559 additions and 4,323 deletions.
2 changes: 1 addition & 1 deletion src/_common
2 changes: 0 additions & 2 deletions src/tree/__tests__/debug.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ tree 针对性测试命令:
```bash
# 执行单测
npx vitest ./src/tree/__tests__/
# 更新单测快照
npx vitest --updateSnapshot ./src/tree/__tests__/
```

## 调试界面
Expand Down
6 changes: 6 additions & 0 deletions src/tree/_example/activable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,18 @@ export default {
methods: {
onClick(context) {
console.info('onClick', context);
const { node } = context;
console.info(node.value, 'actived:', node.actived);
},
onActive(value, context) {
console.info('onActive', value, context);
const { node } = context;
console.info(node.value, 'actived:', node.actived);
},
propOnActive(value, context) {
console.info('propOnActive', value, context);
const { node } = context;
console.info(node.value, 'actived:', node.actived);
},
toggleActivable() {
this.activable = !this.activable;
Expand Down
6 changes: 6 additions & 0 deletions src/tree/_example/checkable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,18 @@ export default {
methods: {
onClick(context) {
console.info('onClick:', context);
const { node } = context;
console.info(node.value, 'checked:', node.checked);
},
onChange(checked, context) {
console.info('onChange:', checked, context);
const { node } = context;
console.info(node.value, 'checked:', node.checked);
},
propOnChange(checked, context) {
console.info('propOnChange:', checked, context);
const { node } = context;
console.info(node.value, 'checked:', node.checked);
},
selectInvert() {
const { tree } = this.$refs;
Expand Down
10 changes: 10 additions & 0 deletions src/tree/_example/controlled.vue
Original file line number Diff line number Diff line change
Expand Up @@ -169,24 +169,34 @@ export default {
methods: {
onClick(context) {
console.info('onClick:', context);
const { node } = context;
console.info(node.value, 'checked:', node.checked);
console.info(node.value, 'expanded:', node.expanded);
console.info(node.value, 'actived:', node.actived);
},
onChange(vals, context) {
console.info('onChange:', vals, context);
const checked = vals.filter((val) => val !== '2.1');
console.info('节点 2.1 不允许选中');
this.checked = checked;
const { node } = context;
console.info(node.value, 'checked:', node.checked);
},
onExpand(vals, context) {
console.info('onExpand:', vals, context);
const expanded = vals.filter((val) => val !== '2');
console.info('节点 2 不允许展开');
this.expanded = expanded;
const { node } = context;
console.info(node.value, 'expanded:', node.expanded);
},
onActive(vals, context) {
console.info('onActive:', vals, context);
const actived = vals.filter((val) => val !== '2');
console.info('节点 2 不允许激活');
this.actived = actived;
const { node } = context;
console.info(node.value, 'actived:', node.actived);
},
},
};
Expand Down
45 changes: 25 additions & 20 deletions src/tree/_example/debug-vscroll.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@
<t-form-item>
<t-button @click="append()">插入根节点</t-button>
</t-form-item>
<t-form-item label="">
<t-input-adornment prepend="filter:">
<t-input v-model="filterText" @change="onInput" />
</t-input-adornment>
</t-form-item>
</t-form>
</t-space>
<t-tree
Expand All @@ -42,6 +47,7 @@
:line="showLine"
:icon="showIcon"
:label="label"
:filter="filterByText"
:scroll="{
rowHeight: 34,
bufferSize: 10,
Expand All @@ -62,7 +68,7 @@
</template>

<script>
const allLevels = [5, 5, 5];
const allLevels = [10, 20, 25];
function createTreeData() {
let cacheIndex = 0;
Expand Down Expand Up @@ -114,27 +120,10 @@ export default {
isCheckable: true,
isOperateAble: true,
items: virtualTree.items,
filterText: '',
filterByText: null,
};
},
computed: {
scroll() {
const { scrollMode } = this;
if (scrollMode === 'normal') {
return null;
}
const scrollProps = {
rowHeight: 34,
bufferSize: 10,
threshold: 10,
};
if (scrollMode === 'lazy') {
scrollProps.type = 'lazy';
} else {
scrollProps.type = 'virtual';
}
return scrollProps;
},
},
methods: {
label(createElement, node) {
return `${node.value}`;
Expand Down Expand Up @@ -162,6 +151,22 @@ export default {
remove(node) {
node.remove();
},
onInput(state) {
console.info('onInput:', state);
if (this.filterText) {
// 存在过滤文案,才启用过滤
this.filterByText = (node) => {
const rs = node.value.indexOf(this.filterText) >= 0;
// 命中的节点会强制展示
// 命中节点的路径节点会锁定展示
// 未命中的节点会隐藏
return rs;
};
} else {
// 过滤文案为空,则还原 tree 为无过滤状态
this.filterByText = null;
}
},
},
};
</script>
91 changes: 39 additions & 52 deletions src/tree/hooks/useTreeNodes.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CreateElement } from 'vue';
import { ref, nextTick, SetupContext } from '@vue/composition-api';
import { ref, SetupContext } from '@vue/composition-api';
import {
TypeVNode, TypeTreeRow, TypeTreeNode, TreeProps, TypeTreeState,
} from '../interface';
Expand All @@ -15,12 +15,48 @@ export default function useTreeNodes(props: TreeProps, context: SetupContext, st
} = treeState;

const { handleClick, handleChange } = useTreeEvents(props, context, state);
const nodesEmpty = ref(false);
// 用于存储已呈现节点的缓存
const cacheMap = new Map();

const refresh = () => {
let list: TypeTreeNode[] = [];
const allNodes = store.getNodes();
const isVirtual = virtualConfig?.isVirtualScroll.value;
if (isVirtual) {
// 虚拟滚动只渲染可见节点
list = virtualConfig.visibleData.value;
nodesEmpty.value = list.length <= 0;
} else {
// 非虚拟滚动,缓存曾经展示过的节点
let hasVisibleNode = false;
allNodes.forEach((node: TypeTreeNode) => {
if (node.visible) {
// 曾经展示过的节点加入缓存,避免再次创建
hasVisibleNode = true;
cacheMap.set(node.value, node.value);
}
if (cacheMap.get(node.value)) {
// 创建的节点是缓存的节点
list.push(node);
}
});
nodesEmpty.value = !hasVisibleNode;
cacheMap.forEach((value) => {
// 在缓存中清理结构变化后不存在的节点
if (!store.getNode(value)) {
cacheMap.delete(value);
}
});
}
// 渲染为平铺列表
nodes.value = list;
};

// 创建单个 tree 节点
const renderItem = (h: CreateElement, node: TypeTreeRow, index: number) => {
const { expandOnClickNode } = props;
const rowIndex = node.__VIRTUAL_SCROLL_INDEX || index;

const treeItem = (
<TreeItem
key={node[privateKey]}
Expand All @@ -35,57 +71,8 @@ export default function useTreeNodes(props: TreeProps, context: SetupContext, st
return treeItem;
};

const cacheMap = new Map();

const nodesEmpty = ref(false);
const refresh = () => {
// 渲染为平铺列表
nodes.value = store.getNodes();
};

const renderTreeNodes = (h: CreateElement) => {
let treeNodeViews: TypeVNode[] = [];
let isEmpty = true;
let list = nodes.value;

const isVirtual = virtualConfig?.isVirtualScroll.value;
if (isVirtual) {
list = virtualConfig.visibleData.value;
nodesEmpty.value = list.length <= 0;
// 虚拟滚动只渲染可见节点
treeNodeViews = list.map((node: TypeTreeNode, index) => renderItem(h, node, index));
} else {
treeNodeViews = list.map((node: TypeTreeNode, index) => {
const nodePrivateKey = node[privateKey];
// 如果节点已经存在,则使用缓存节点
// 不可见的节点,缓存中存在,则依然会保留
let nodeView: TypeVNode = cacheMap.get(nodePrivateKey);
if (node.visible) {
// 任意一个节点可视,过滤结果就不是空
isEmpty = false;
// 如果节点未曾创建,则临时创建
if (!nodeView) {
// 初次仅渲染可显示的节点
// 不存在节点视图,则创建该节点视图并插入到当前位置
nodeView = renderItem(h, node, index);
cacheMap.set(nodePrivateKey, nodeView);
}
}
return nodeView;
});
nodesEmpty.value = isEmpty;

// 更新缓存后,被删除的节点要移除掉,避免内存泄露
nextTick(() => {
cacheMap.forEach((view: TypeVNode, nodePrivateKey: string) => {
const node = store.privateMap.get(nodePrivateKey);
if (!node) {
cacheMap.delete(nodePrivateKey);
}
});
});
}

const treeNodeViews: TypeVNode[] = nodes.value.map((node: TypeTreeNode, index) => renderItem(h, node, index));
return treeNodeViews;
};

Expand Down
5 changes: 3 additions & 2 deletions src/tree/hooks/useTreeScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { TreeProps, TypeTreeState, TypeTimer } from '../interface';
export default function useTreeScroll(props: TreeProps, context: SetupContext, state: TypeTreeState) {
const treeState = state;
const {
scope, treeContentRef, nodes, isScrolling,
allNodes, nodes, scope, treeContentRef, isScrolling,
} = treeState;

const scrollProps: Ref<TScroll> = computed(() => ({
Expand All @@ -22,7 +22,7 @@ export default function useTreeScroll(props: TreeProps, context: SetupContext, s

// 虚拟滚动
const virtualScrollParams = computed(() => {
const list = nodes.value.filter((node: TreeNode) => node.visible);
const list = allNodes.value.filter((node: TreeNode) => node.visible);
return {
data: list,
scroll: scrollProps.value,
Expand Down Expand Up @@ -70,6 +70,7 @@ export default function useTreeScroll(props: TreeProps, context: SetupContext, s
if (lastScrollY !== top) {
if (isVirtual) {
virtualConfig.handleScroll();
nodes.value = virtualConfig.visibleData.value;
}
} else {
lastScrollY = 0;
Expand Down
5 changes: 5 additions & 0 deletions src/tree/hooks/useTreeState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,20 @@ import TreeNode from '../../_common/js/tree/tree-node';
export default function useTreeState(props: TreeProps, store: TypeTreeStore) {
const treeContentRef = ref<HTMLDivElement>();
const nodes: Ref<TreeNode[]> = ref([]);
const allNodes: Ref<TreeNode[]> = ref([]);
const isScrolling: Ref<boolean> = ref(false);

allNodes.value = store.getNodes();

const state: TypeTreeState = {
// tree 数据对象
store,
// 内容根节点
treeContentRef,
// 渲染节点
nodes,
// 所有节点
allNodes,
// 是否正在滚动
isScrolling,
// 缓存点击事件
Expand Down
3 changes: 2 additions & 1 deletion src/tree/hooks/useTreeStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export default function useTreeStore(props: TreeProps, context: SetupContext) {
// 所以在 update 之后检查,如果之前 filter 有变更,则检查路径节点是否需要展开
// 如果 filter 属性被清空,则重置为开启搜索之前的结果
const expandFilterPath = () => {
if (!props.allowFoldNodeOnFilter) return;
if (!filterChanged) return;
// 确保 filter 属性未变更时,不会重复检查展开状态
filterChanged = false;
Expand Down Expand Up @@ -102,7 +103,7 @@ export default function useTreeStore(props: TreeProps, context: SetupContext) {
};

// 这个方法监听 filter 属性,仅在 allowFoldNodeOnFilter 属性为 true 时生效
// 仅在 filter 属性发生变更时开启检查开关,避免其他操作也触发展开状态的充值
// 仅在 filter 属性发生变更时开启检查开关,避免其他操作也触发展开状态的重置
const checkFilterExpand = (newFilter: null | Function, previousFilter: null | Function) => {
if (!props.allowFoldNodeOnFilter) return;
filterChanged = newFilter !== previousFilter;
Expand Down
1 change: 1 addition & 0 deletions src/tree/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export interface TypeTreeState {
scope: TypeTreeScope;
store: TypeTreeStore;
nodes: Ref<TreeNode[]>;
allNodes: Ref<TreeNode[]>;
isScrolling: Ref<boolean>;
treeContentRef: Ref<HTMLDivElement>;
mouseEvent?: Event;
Expand Down
Loading

0 comments on commit 55eb29a

Please sign in to comment.