feat: optimize updateFlatList O(N+M) and release base v1.0.3

This commit is contained in:
AI Engineer 2026-05-19 07:18:01 +08:00
parent 388a4d69b1
commit 08e448f845
5 changed files with 234 additions and 542 deletions

339
dist/base.js vendored
View File

@ -362,14 +362,8 @@ const VirtualScroll = () => {
const groupHeights = /* @__PURE__ */ new Map(); const groupHeights = /* @__PURE__ */ new Map();
let groupItemCount = 1; let groupItemCount = 1;
const avg = Util.newAvg(); const avg = Util.newAvg();
let padTop = 0; let padTop = 0, rowGap = 0, topMargin = 0, itemMarginTop = null, itemMarginBottom = null, listInited = false;
let rowGap = 0;
let topMargin = 0;
let itemMarginTop = null;
let itemMarginBottom = null;
let listInited = false;
return { return {
// 核心1重置与首屏预渲染
reset: (list, container) => { reset: (list, container) => {
listInited = false; listInited = false;
itemHeights.clear(); itemHeights.clear();
@ -386,25 +380,18 @@ const VirtualScroll = () => {
rowGap = parseFloat(style.rowGap) || 0; rowGap = parseFloat(style.rowGap) || 0;
return list.slice(0, Math.min(30, size)); return list.slice(0, Math.min(30, size));
}, },
// 核心2无痛初始化缓存 (通过 rAF 调用)
init: (list, refreshCallback) => { init: (list, refreshCallback) => {
if (listInited) return; if (listInited) return;
const size = list.length; const size = list.length, defaultHeight = avg.get() || 32;
const defaultHeight = avg.get() || 32; for (let i = 0; i < size; i++) if (!itemHeights.has(i)) itemHeights.set(i, defaultHeight);
for (let i = 0; i < size; i++) {
if (!itemHeights.has(i)) itemHeights.set(i, defaultHeight);
}
for (let i = 0; i < size; i += groupItemCount) { for (let i = 0; i < size; i += groupItemCount) {
let sum = 0; let sum = 0;
for (let j = i; j < Math.min(i + groupItemCount, size); j++) { for (let j = i; j < Math.min(i + groupItemCount, size); j++) sum += itemHeights.get(j);
sum += itemHeights.get(j);
}
groupHeights.set(i, sum); groupHeights.set(i, sum);
} }
listInited = true; listInited = true;
refreshCallback(); refreshCallback();
}, },
// 核心3原汁原味的极速高度更新
update: (absoluteIndex, node) => { update: (absoluteIndex, node) => {
if (itemMarginTop === null) { if (itemMarginTop === null) {
const style = window.getComputedStyle(node); const style = window.getComputedStyle(node);
@ -417,21 +404,14 @@ const VirtualScroll = () => {
if (newHeight !== oldHeight) { if (newHeight !== oldHeight) {
itemHeights.set(absoluteIndex, newHeight); itemHeights.set(absoluteIndex, newHeight);
avg.add(newHeight); avg.add(newHeight);
const offset = newHeight - (oldHeight || 0); const offset = newHeight - (oldHeight || 0), groupIndex = absoluteIndex - absoluteIndex % groupItemCount;
const groupIndex = absoluteIndex - absoluteIndex % groupItemCount;
if (groupHeights.has(groupIndex)) groupHeights.set(groupIndex, groupHeights.get(groupIndex) + offset); if (groupHeights.has(groupIndex)) groupHeights.set(groupIndex, groupHeights.get(groupIndex) + offset);
} }
}, },
// 核心4原汁原味的状态机滚动计算
calc: (container, list) => { calc: (container, list) => {
if (!listInited || !list) return null; if (!listInited || !list) return null;
const size = list.length; const size = list.length, visibleCount = Math.ceil((container.clientHeight || 100) / (avg.get() || 32));
const visibleCount = Math.ceil((container.clientHeight || 100) / (avg.get() || 32)); let prev = padTop + topMargin + rowGap, post = 0, status = 0, listStartIndex = 0, listEndIndex = 0;
let prev = padTop + topMargin + rowGap;
let post = 0;
let status = 0;
let listStartIndex = 0;
let listEndIndex = 0;
for (let i = 0; i < size; i++) { for (let i = 0; i < size; i++) {
if (status === 0) { if (status === 0) {
const gh = groupHeights.get(i); const gh = groupHeights.get(i);
@ -440,9 +420,8 @@ const VirtualScroll = () => {
i += Math.min(groupItemCount, size - i) - 1; i += Math.min(groupItemCount, size - i) - 1;
} else { } else {
const ih = itemHeights.get(i); const ih = itemHeights.get(i);
if (prev + ih < container.scrollTop) { if (prev + ih < container.scrollTop) prev += ih;
prev += ih; else {
} else {
status = 1; status = 1;
let visibleStartIndex = Math.max(0, i); let visibleStartIndex = Math.max(0, i);
listStartIndex = Math.max(0, visibleStartIndex - visibleCount); listStartIndex = Math.max(0, visibleStartIndex - visibleCount);
@ -456,248 +435,130 @@ const VirtualScroll = () => {
if (gh) { if (gh) {
post += gh; post += gh;
i += groupItemCount - 1; i += groupItemCount - 1;
} else { } else post += itemHeights.get(i);
post += itemHeights.get(i);
} }
} }
} return { prevHeight: Math.max(0, prev - padTop - topMargin - rowGap), postHeight: post, renderedList: list.slice(listStartIndex, listEndIndex), listStartIndex };
return {
prevHeight: prev - padTop - topMargin - rowGap,
postHeight: post,
renderedList: list.slice(listStartIndex, listEndIndex),
listStartIndex
};
} }
}; };
}; };
const FastListComponent = Component.register("FastList", (container) => { Component.register("List", (container) => {
const vs = VirtualScroll(); container.mode = container.getAttribute("mode") || "normal";
container.state.renderedList = []; container.fast = container.hasAttribute("fast");
container.onItemUpdate = (index2, node) => { container.collapsible = container.hasAttribute("collapsible");
vs.update(index2 + (container.state._listStartIndex || 0), node); const defaultSets = {
idfield: "id",
labelfield: "label",
summaryfield: "summary",
groupidfield: "id",
grouplabelfield: "label",
groupsummaryfield: "summary",
groupfield: "group",
parentfield: "parent",
groupicon: "folder",
itemicon: "file"
}; };
container.collapsed = NewState({});
const updateFlatList = () => {
Util.updateDefaults(container, defaultSets);
const list = container.state.list || [], flatList = [];
if (container.mode === "group") {
const itemMap = {};
list.forEach((item) => {
var _a;
return (itemMap[_a = item[container.groupfield]] ?? (itemMap[_a] = [])).push(item);
});
(container.state.groups || []).forEach((group) => {
flatList.push({ type: "group", ...group });
const items = itemMap[group[container.groupidfield]];
if (items) items.forEach((item) => flatList.push({ type: "item", ...item }));
});
} else if (container.mode === "tree") {
const childrenMap = {};
list.forEach((item) => {
var _a;
return (childrenMap[_a = item[container.parentfield] || ""] ?? (childrenMap[_a] = [])).push(item);
});
const traverse = (items, level, parents) => items.forEach((item) => {
var _a;
const id = item[container.idfield], hasChildren = !!((_a = childrenMap[id]) == null ? void 0 : _a.length);
const isCollapsed = container.collapsed[id];
flatList.push({ type: "item", ...item, _level: level, _hasChildren: hasChildren, _parents: parents });
if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id]);
});
traverse(childrenMap[""] || [], 0, []);
} else list.forEach((item) => flatList.push({ type: "item", ...item }));
container.state._flatList = flatList;
};
container.state.__watch("list", updateFlatList);
const vs = container.fast ? VirtualScroll() : null;
container.state._renderedList = [];
container.refresh = () => { container.refresh = () => {
const res = vs.calc(container, container.state.list); if (!container.fast) return;
const res = vs.calc(container, container.state._flatList);
if (res) { if (res) {
container.state.prevHeight = res.prevHeight; container.state.prevHeight = res.prevHeight;
container.state.postHeight = res.postHeight; container.state.postHeight = res.postHeight;
container.state._listStartIndex = res.listStartIndex; container.state._listStartIndex = res.listStartIndex;
container.state.renderedList = res.renderedList; container.state._renderedList = res.renderedList;
} }
}; };
container.state.__watch("list", (list) => { container.onItemUpdate = (index2, node) => {
if (container.fast) vs.update(index2 + (container.state._listStartIndex || 0), node);
};
container.state.__watch("_flatList", (flatList) => {
if (container.fast) {
container.state._listStartIndex = 0; container.state._listStartIndex = 0;
container.state.renderedList = vs.reset(list, container) || []; container.state._renderedList = vs.reset(flatList, container) || [];
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (container.state.list !== list) return; if (container.state._flatList === flatList) vs.init(flatList, container.refresh);
vs.init(list, container.refresh);
}); });
} else container.state._renderedList = flatList;
}); });
}, Util.makeDom(
/*html*/
`
<div class="overflow-auto" onscroll="this.refresh()" style="overflow-anchor:none">
<div $if="this.state.prevHeight>0" $style="height:\${this.state.prevHeight}px;" class="flex-shrink-0"></div>
<div slot-id="item" class="flex-shrink-0" $each="this.state.renderedList" $onupdate="this.onItemUpdate(index,thisNode)"></div>
<div $if="this.state.postHeight>0" $style="height:\${this.state.postHeight}px;" class="flex-shrink-0"></div>
</div>
`
));
Component.attachSelectFeature = (container, eventName = "itemclick", selectActionName = "selectItem", activeActionName = "itemActiveTag", selectedItemKey = "selectedItem", idfieldKey = "idfield", selectableAttr = "auto-select") => {
const selectable = selectableAttr && container.hasAttribute(selectableAttr);
container[selectActionName] = (item, index2) => {
if (selectable) container.state[selectedItemKey] = container.state[selectedItemKey] === item[container[idfieldKey]] ? null : item[container[idfieldKey]];
container.dispatchEvent(new CustomEvent(eventName, { bubbles: false, detail: !selectable || container.state[selectedItemKey] ? { item, index: index2 } : { item: {}, index: null } }));
};
container[activeActionName] = (item) => container.state[selectedItemKey] !== null && container.state[selectedItemKey] === item[container[idfieldKey]] ? "active" : "";
};
Component.register("List", (container) => {
Component.attachSelectFeature(container);
const defaultSets = { idfield: "id", labelfield: "label", summaryfield: "summary" };
container.state.__watch("list", (oldList) => {
Util.updateDefaults(container, defaultSets);
});
}, Util.makeDom(
/*html*/
`
<div class="list-group overflow-auto">
<div slot="item" slot-id="item" $each="this.state.list" $onclick="this.selectItem(item,index)" $class="list-group-item list-group-item-action list-group-item-heading d-inline-flex pe-auto align-items-baseline \${this.itemActiveTag?.(item)}">
<span $if="this.itemicon" class="bi bi-\${this.itemicon} text-body"></span>
<div class="flex-shrink-0 px-1" $text>\${item[this.labelfield]}</div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
<div slot-id="item-actions"></div>
</div>
</div>
`
));
Component.register("GroupedList", (container) => {
Component.getSetupFunction("List")(container);
container._selectItem = container.selectItem;
container.selectItem = (item, index2) => { container.selectItem = (item, index2) => {
container.state.selectedGroup = null; if (container.hasAttribute("auto-select")) container.state.selectedItem = container.state.selectedItem === item[container.idfield] ? null : item[container.idfield];
container._selectItem(item, index2); container.dispatchEvent(new CustomEvent("itemclick", { bubbles: false, detail: { item, index: index2 + (container.fast ? container.state._listStartIndex || 0 : 0) } }));
}; };
Component.attachSelectFeature(container, "groupclick", "selectGroup", "groupActiveTag", "selectedGroup", "groupidfield", container.hasAttribute("auto-select-group") || container.hasAttribute("auto-select") && "group-selectable");
container._selectGroup = container.selectGroup;
container.selectGroup = (item, index2) => { container.selectGroup = (item, index2) => {
container.state.selectedItem = null; if (container.hasAttribute("auto-select-group")) container.state.selectedGroup = container.state.selectedGroup === item[container.groupidfield] ? null : item[container.groupidfield];
container._selectGroup(item, index2); container.dispatchEvent(new CustomEvent("groupclick", { bubbles: false, detail: { item, index: index2 } }));
}; };
const defaultSets = { typefield: "type", groupidfield: "id", grouplabelfield: "label", groupsummaryfield: "summary", groupfield: "group" }; container.toggleCollapse = (item) => {
container.state.__watch("list", (oldList) => { if (container.collapsible && item._hasChildren) {
Util.updateDefaults(container, defaultSets); container.collapsed[item[container.idfield]] = !container.collapsed[item[container.idfield]];
const newList = []; updateFlatList();
(container.state.groups || []).forEach((group) => {
newList.push({ [container.typefield]: "group", ...group });
if (oldList) oldList.forEach((item) => {
if (item[container.groupfield] === group[container.groupidfield]) {
newList.push({ [container.typefield]: "item", ...item });
} }
}); };
}); updateFlatList();
return newList;
});
}, Util.makeDom( }, Util.makeDom(
/*html*/ /*html*/
` `
<div class="list-group overflow-auto"> <div class="list-group overflow-auto" $onscroll="this.fast && this.refresh()" style="overflow-anchor:none">
<template $each="this.state.list"> <div $if="this.fast && (this.state?.prevHeight || 0) > 0" $style="height:\${this.state?.prevHeight}px;" class="flex-shrink-0"></div>
<div slot-id="group" $if="item.type === 'group'" $onclick="this.selectGroup(item,index)" style="height: 36px" $class="list-group-item list-group-item-action small d-inline-flex align-items-center ps-2 pe-2 \${this.groupActiveTag(item)}"> <template slot-id="item" $each="this.state?._renderedList">
<div $onupdate="this.onItemUpdate(index, thisNode)" $class="list-group-item list-group-item-action d-inline-flex align-items-center ps-2 pe-2 \${item.type==='group'?(this.state?.selectedGroup===item[this.groupidfield]?'active':''):(this.state?.selectedItem===item[this.idfield]?'active':'')}" $onclick="item.type==='group'?this.selectGroup(item,index):this.selectItem(item,index)">
<template $if="item.type === 'group'">
<span $if="this.groupicon" $class="bi bi-\${this.groupicon} text-body"></span> <span $if="this.groupicon" $class="bi bi-\${this.groupicon} text-body"></span>
<div class="flex-shrink-0 px-1" $text>\${item[this.grouplabelfield]}</div> <div class="flex-shrink-0 px-1" $text="\${item[this.grouplabelfield]}"></div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.groupsummaryfield]"></div> <div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="\${item[this.groupsummaryfield]}"></div>
<div slot-id="group-actions"></div> <div slot-id="group-actions"></div>
</template>
<template $if="item.type === 'item'">
<div $if="this.mode === 'tree'" $style="width:\${item._level * 16 + (this.collapsible ? 16 : 0)}px; cursor:\${this.collapsible ? 'pointer' : 'default'}" class="text-end text-muted flex-shrink-0" $onclick="event.stopPropagation(); this.toggleCollapse(item)">
<i $if="this.collapsible && item._hasChildren" $class="bi \${this.collapsed[item[this.idfield]] ? 'bi-caret-right-fill' : 'bi-caret-down-fill'}"></i>
</div> </div>
<div slot-id="item" $if="item.type === 'item'" $onclick="this.selectItem(item,index)" $class="list-group-item list-group-item-action d-inline-flex align-items-baseline ps-3 pe-2 \${this.itemActiveTag?.(item)}"> <span $if="this.mode === 'tree'" $class="text-muted bi bi-\${item._hasChildren ? this.groupicon : this.itemicon}"></span>
<span $if="this.itemicon" $class="bi bi-\${this.itemicon} text-body"></span> <span $if="this.mode !== 'tree' && this.itemicon" class="bi bi-\${this.itemicon} text-body"></span>
<div class="flex-shrink-0 px-1" $text>\${item[this.labelfield]}</div> <div class="flex-shrink-0 px-1" $text="\${item[this.labelfield]}"></div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div> <div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="\${item[this.summaryfield]}"></div>
<div slot-id="item-actions"></div> <div slot-id="item-actions"></div>
</template>
</div> </div>
</template> </template>
<div $if="this.fast && (this.state?.postHeight || 0) > 0" $style="height:\${this.state?.postHeight}px;" class="flex-shrink-0"></div>
</div> </div>
` `
)); ));
Component.register("FastGroupedList", (container) => {
Component.getSetupFunction("List")(container);
Component.getSetupFunction("GroupedList")(container);
container.refresh = () => {
var _a;
return (_a = container.querySelector("FastList")) == null ? void 0 : _a.refresh();
};
}, Util.makeDom(
/*html*/
`
<FastList class="list-group" $.state.list="this.state.list">
<div slot="item">
<div slot-id="group" $if="item.type === 'group'" $onclick="this.selectGroup(item,index)" style="height: 36px" $class="list-group-item list-group-item-action small d-inline-flex align-items-center ps-2 pe-2 \${this.groupActiveTag(item)}">
<span $if="this.groupicon" $class="bi bi-\${this.groupicon} text-body"></span>
<div class="flex-shrink-0 px-1" $text>\${item[this.grouplabelfield]}</div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.groupsummaryfield]"></div>
<div slot-id="group-actions"></div>
</div>
<div slot-id="item" $if="item.type === 'item'" $onclick="this.selectItem(item,index)" $class="list-group-item list-group-item-action d-inline-flex align-items-baseline ps-3 pe-2 \${this.itemActiveTag?.(item)}">
<span $if="this.itemicon" $class="bi bi-\${this.itemicon} text-body"></span>
<div class="flex-shrink-0 px-1" $text>\${item[this.labelfield]}</div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
<div slot-id="item-actions"></div>
</div>
</div>
</FastList>
`
));
Component.register("Tree", (container) => {
const defaultSets = { parentfield: "parent", groupicon: "folder", itemicon: "file" };
container.state.__watch("list", () => {
Util.updateDefaults(container, defaultSets);
const childrenMap = {};
(container.state.list || []).forEach((item) => {
var _a;
return (childrenMap[_a = item[container.parentfield] || ""] ?? (childrenMap[_a] = [])).push(item);
});
const newList = [];
const traverse = (list, level) => list.forEach((item) => {
var _a;
const id = item[container.idfield];
const hasChildren = !!((_a = childrenMap[id]) == null ? void 0 : _a.length);
newList.push({ ...item, _level: level, _hasChildren: hasChildren });
if (hasChildren) traverse(childrenMap[id], level + 1);
});
traverse(childrenMap[""] || [], 0);
return newList;
});
}, Util.makeDom(
/*html*/
`
<List>
<div slot="item" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
<div $style="width:\${item._level * 16}px" class="flex-shrink-0"></div>
<div $class="text-muted bi bi-\${item._hasChildren?this.groupicon:this.itemicon}"></div>
<div class="flex-grow-1 text-truncate px-2" $text="item[this.labelfield]"></div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
<div slot-id="item-actions"></div>
</div>
</List>
`
));
const FastTreeComponent = Component.register("FastTree", (container) => {
Component.getSetupFunction("List")(container);
Component.getSetupFunction("Tree")(container);
container.refresh = () => {
var _a;
return (_a = container.querySelector("FastList")) == null ? void 0 : _a.refresh();
};
}, Util.makeDom(
/*html*/
`
<FastList class="list-group list-group-action" $.state.list="this.state.list">
<div slot="item" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
<div $style="width:\${item._level * 16}px" class="flex-shrink-0"></div>
<div $class="text-muted bi bi-\${item._hasChildren?this.groupicon:this.itemicon}"></div>
<div class="flex-grow-1 text-truncate px-2" $text="item[this.labelfield]"></div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
<div slot-id="item-actions"></div>
</div>
</FastList>
`
));
Component.register("CollapseTree", (container) => {
Component.attachSelectFeature(container);
const defaultSets = { idfield: "id", parentfield: "parent", labelfield: "label", summaryfield: "summary" };
container.collapsed = NewState({});
container.state.__watch("list", () => {
Util.updateDefaults(container, defaultSets);
const childrenMap = {};
(container.state.list || []).forEach((item) => {
var _a;
return (childrenMap[_a = item[container.parentfield] || ""] ?? (childrenMap[_a] = [])).push(item);
});
const newList = [];
const traverse = (list, level, parents) => list.forEach((item) => {
var _a;
const id = item[container.idfield];
const hasChildren = !!((_a = childrenMap[id]) == null ? void 0 : _a.length);
newList.push({ ...item, _level: level, _hasChildren: hasChildren, _parents: parents });
if (hasChildren) traverse(childrenMap[id], level + 1, [...parents, id]);
});
traverse(childrenMap[""] || [], 0, []);
return newList;
});
}, Util.makeDom(
/*html*/
`
<List>
<div slot="item" slot-id="item" $if="!item._parents.some(cur=>this.collapsed[cur])" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
<div $style="width:\${item._level * 16 + 16}px; cursor: pointer;" class="text-end text-muted flex-shrink-0" $onclick="event.stopPropagation();this.collapsed[item[this.idfield]] = !this.collapsed[item[this.idfield]]">
<i $if="item._hasChildren" $class="bi \${this.collapsed[item[this.idfield]] ? 'bi-caret-right-fill' : 'bi-caret-down-fill'}"></i>
</div>
<div class="flex-grow-1 text-truncate px-1" $text="item[this.labelfield]"></div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
<div slot-id="item-actions"></div>
</div>
</List>
`
));
Component.register("Nav", (container) => { Component.register("Nav", (container) => {
container.click = (item, noselect) => { container.click = (item, noselect) => {
if (!item.noselect && !noselect) Hash.nav = item.name; if (!item.noselect && !noselect) Hash.nav = item.name;
@ -818,8 +679,6 @@ if (typeof document !== "undefined") {
export { export {
APIComponent, APIComponent,
AutoForm, AutoForm,
FastListComponent,
FastTreeComponent,
HTTP, HTTP,
MouseMover, MouseMover,
State, State,

2
dist/base.min.js vendored

File diff suppressed because one or more lines are too long

View File

@ -5,334 +5,166 @@ export const VirtualScroll = () => {
const groupHeights = new Map() const groupHeights = new Map()
let groupItemCount = 1 let groupItemCount = 1
const avg = Util.newAvg() const avg = Util.newAvg()
let padTop = 0, rowGap = 0, topMargin = 0, itemMarginTop = null, itemMarginBottom = null, listInited = false
let padTop = 0
let rowGap = 0
let topMargin = 0
let itemMarginTop = null
let itemMarginBottom = null
let listInited = false
return { return {
// 核心1重置与首屏预渲染
reset: (list, container) => { reset: (list, container) => {
listInited = false listInited = false; itemHeights.clear(); groupHeights.clear(); avg.clear(); topMargin = 0; itemMarginTop = null; itemMarginBottom = null;
itemHeights.clear() if (!list?.length) return [];
groupHeights.clear() const size = list.length; groupItemCount = Math.ceil(Math.sqrt(size)) || 10;
avg.clear() const style = window.getComputedStyle(container);
topMargin = 0 padTop = parseFloat(style.paddingTop) || 0; rowGap = parseFloat(style.rowGap) || 0;
itemMarginTop = null return list.slice(0, Math.min(30, size));
itemMarginBottom = null
if (!list?.length) return []
const size = list.length
groupItemCount = Math.ceil(Math.sqrt(size)) || 10
const style = window.getComputedStyle(container)
padTop = parseFloat(style.paddingTop) || 0
rowGap = parseFloat(style.rowGap) || 0
return list.slice(0, Math.min(30, size))
}, },
// 核心2无痛初始化缓存 (通过 rAF 调用)
init: (list, refreshCallback) => { init: (list, refreshCallback) => {
if (listInited) return if (listInited) return;
const size = list.length const size = list.length, defaultHeight = avg.get() || 32;
const defaultHeight = avg.get() || 32 for (let i = 0; i < size; i++) if (!itemHeights.has(i)) itemHeights.set(i, defaultHeight);
for (let i = 0; i < size; i++) {
if (!itemHeights.has(i)) itemHeights.set(i, defaultHeight)
}
for (let i = 0; i < size; i += groupItemCount) { for (let i = 0; i < size; i += groupItemCount) {
let sum = 0 let sum = 0;
for (let j = i; j < Math.min(i + groupItemCount, size); j++) { for (let j = i; j < Math.min(i + groupItemCount, size); j++) sum += itemHeights.get(j);
sum += itemHeights.get(j) groupHeights.set(i, sum);
} }
groupHeights.set(i, sum) listInited = true; refreshCallback();
}
listInited = true
refreshCallback()
}, },
// 核心3原汁原味的极速高度更新
update: (absoluteIndex, node) => { update: (absoluteIndex, node) => {
if (itemMarginTop === null) { if (itemMarginTop === null) {
const style = window.getComputedStyle(node) const style = window.getComputedStyle(node);
itemMarginTop = parseFloat(style.marginTop) || 0 itemMarginTop = parseFloat(style.marginTop) || 0; itemMarginBottom = parseFloat(style.marginBottom) || 0;
itemMarginBottom = parseFloat(style.marginBottom) || 0
} }
if (absoluteIndex === 0) topMargin = itemMarginTop if (absoluteIndex === 0) topMargin = itemMarginTop;
const newHeight = node.offsetHeight + itemMarginTop + itemMarginBottom + rowGap;
const newHeight = node.offsetHeight + itemMarginTop + itemMarginBottom + rowGap const oldHeight = itemHeights.get(absoluteIndex);
const oldHeight = itemHeights.get(absoluteIndex)
if (newHeight !== oldHeight) { if (newHeight !== oldHeight) {
itemHeights.set(absoluteIndex, newHeight) itemHeights.set(absoluteIndex, newHeight); avg.add(newHeight);
avg.add(newHeight) const offset = newHeight - (oldHeight || 0), groupIndex = absoluteIndex - (absoluteIndex % groupItemCount);
const offset = newHeight - (oldHeight || 0) if (groupHeights.has(groupIndex)) groupHeights.set(groupIndex, groupHeights.get(groupIndex) + offset);
const groupIndex = absoluteIndex - (absoluteIndex % groupItemCount)
if (groupHeights.has(groupIndex)) groupHeights.set(groupIndex, groupHeights.get(groupIndex) + offset)
} }
}, },
// 核心4原汁原味的状态机滚动计算
calc: (container, list) => { calc: (container, list) => {
if (!listInited || !list) return null if (!listInited || !list) return null;
const size = list.length, visibleCount = Math.ceil((container.clientHeight || 100) / (avg.get() || 32));
const size = list.length let prev = padTop + topMargin + rowGap, post = 0, status = 0, listStartIndex = 0, listEndIndex = 0;
const visibleCount = Math.ceil((container.clientHeight || 100) / (avg.get() || 32))
let prev = padTop + topMargin + rowGap
let post = 0
let status = 0
let listStartIndex = 0
let listEndIndex = 0
for (let i = 0; i < size; i++) { for (let i = 0; i < size; i++) {
if (status === 0) { if (status === 0) {
const gh = groupHeights.get(i) const gh = groupHeights.get(i);
if (gh && prev + gh < container.scrollTop) { if (gh && prev + gh < container.scrollTop) { prev += gh; i += Math.min(groupItemCount, size - i) - 1; }
prev += gh else {
i += Math.min(groupItemCount, size - i) - 1 const ih = itemHeights.get(i);
} else { if (prev + ih < container.scrollTop) prev += ih;
const ih = itemHeights.get(i) else {
if (prev + ih < container.scrollTop) { status = 1; let visibleStartIndex = Math.max(0, i);
prev += ih listStartIndex = Math.max(0, visibleStartIndex - visibleCount);
} else { listEndIndex = Math.min(listStartIndex + visibleCount * 3, size);
status = 1 i = listEndIndex - 1; for (let j = listStartIndex; j < visibleStartIndex; j++) prev -= itemHeights.get(j);
let visibleStartIndex = Math.max(0, i)
listStartIndex = Math.max(0, visibleStartIndex - visibleCount)
listEndIndex = Math.min(listStartIndex + visibleCount * 3, size)
i = listEndIndex - 1
for (let j = listStartIndex; j < visibleStartIndex; j++) prev -= itemHeights.get(j)
} }
} }
} else if (status === 1) { } else if (status === 1) {
const gh = groupHeights.get(i) const gh = groupHeights.get(i);
if (gh) { if (gh) { post += gh; i += groupItemCount - 1; } else post += itemHeights.get(i);
post += gh
i += groupItemCount - 1
} else {
post += itemHeights.get(i)
} }
} }
} return { prevHeight: Math.max(0, prev - padTop - topMargin - rowGap), postHeight: post, renderedList: list.slice(listStartIndex, listEndIndex), listStartIndex };
return {
prevHeight: prev - padTop - topMargin - rowGap,
postHeight: post,
renderedList: list.slice(listStartIndex, listEndIndex),
listStartIndex
}
} }
} }
} }
export const FastListComponent = Component.register('FastList', container => { Component.register('List', container => {
const vs = VirtualScroll() container.mode = container.getAttribute('mode') || 'normal'
container.state.renderedList = [] container.fast = container.hasAttribute('fast')
container.collapsible = container.hasAttribute('collapsible')
container.onItemUpdate = (index, node) => { const defaultSets = {
vs.update(index + (container.state._listStartIndex || 0), node) idfield: 'id', labelfield: 'label', summaryfield: 'summary',
groupidfield: 'id', grouplabelfield: 'label', groupsummaryfield: 'summary', groupfield: 'group',
parentfield: 'parent', groupicon: 'folder', itemicon: 'file'
}
container.collapsed = NewState({})
const updateFlatList = () => {
Util.updateDefaults(container, defaultSets)
const list = container.state.list || [], flatList = []
if (container.mode === 'group') {
const itemMap = {}
list.forEach(item => (itemMap[item[container.groupfield]] ??= []).push(item));
(container.state.groups || []).forEach(group => {
flatList.push({ type: 'group', ...group })
const items = itemMap[group[container.groupidfield]]
if (items) items.forEach(item => flatList.push({ type: 'item', ...item }))
})
} else if (container.mode === 'tree') {
const childrenMap = {}
list.forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
const traverse = (items, level, parents) => items.forEach(item => {
const id = item[container.idfield], hasChildren = !!childrenMap[id]?.length
const isCollapsed = container.collapsed[id]
flatList.push({ type: 'item', ...item, _level: level, _hasChildren: hasChildren, _parents: parents })
if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id])
})
traverse(childrenMap[''] || [], 0, [])
} else list.forEach(item => flatList.push({ type: 'item', ...item }))
container.state._flatList = flatList
} }
container.state.__watch('list', updateFlatList)
const vs = container.fast ? VirtualScroll() : null
container.state._renderedList = []
container.refresh = () => { container.refresh = () => {
const res = vs.calc(container, container.state.list) if (!container.fast) return
const res = vs.calc(container, container.state._flatList)
if (res) { if (res) {
container.state.prevHeight = res.prevHeight container.state.prevHeight = res.prevHeight
container.state.postHeight = res.postHeight container.state.postHeight = res.postHeight
container.state._listStartIndex = res.listStartIndex container.state._listStartIndex = res.listStartIndex
container.state.renderedList = res.renderedList container.state._renderedList = res.renderedList
} }
} }
container.state.__watch('list', list => { container.onItemUpdate = (index, node) => { if (container.fast) vs.update(index + (container.state._listStartIndex || 0), node) }
container.state.__watch('_flatList', flatList => {
if (container.fast) {
container.state._listStartIndex = 0 container.state._listStartIndex = 0
container.state.renderedList = vs.reset(list, container) || [] container.state._renderedList = vs.reset(flatList, container) || []
requestAnimationFrame(() => { if (container.state._flatList === flatList) vs.init(flatList, container.refresh) })
requestAnimationFrame(() => { } else container.state._renderedList = flatList
if (container.state.list !== list) return
vs.init(list, container.refresh)
})
}) })
}, Util.makeDom(/*html*/`
<div class="overflow-auto" onscroll="this.refresh()" style="overflow-anchor:none">
<div $if="this.state.prevHeight>0" $style="height:\${this.state.prevHeight}px;" class="flex-shrink-0"></div>
<div slot-id="item" class="flex-shrink-0" $each="this.state.renderedList" $onupdate="this.onItemUpdate(index,thisNode)"></div>
<div $if="this.state.postHeight>0" $style="height:\${this.state.postHeight}px;" class="flex-shrink-0"></div>
</div>
`))
Component.attachSelectFeature = (container, eventName = 'itemclick', selectActionName = 'selectItem', activeActionName = 'itemActiveTag', selectedItemKey = 'selectedItem', idfieldKey = 'idfield', selectableAttr = 'auto-select') => {
const selectable = selectableAttr && container.hasAttribute(selectableAttr)
container[selectActionName] = (item, index) => {
if (selectable) container.state[selectedItemKey] = container.state[selectedItemKey] === item[container[idfieldKey]] ? null : item[container[idfieldKey]]
container.dispatchEvent(new CustomEvent(eventName, { bubbles: false, detail: !selectable || container.state[selectedItemKey] ? { item, index } : { item: {}, index: null } }))
}
container[activeActionName] = (item) => container.state[selectedItemKey] !== null && container.state[selectedItemKey] === item[container[idfieldKey]] ? 'active' : ''
}
Component.register('List', container => {
Component.attachSelectFeature(container)
const defaultSets = { idfield: 'id', labelfield: 'label', summaryfield: 'summary' }
container.state.__watch('list', oldList => {
Util.updateDefaults(container, defaultSets)
})
}, Util.makeDom(/*html*/`
<div class="list-group overflow-auto">
<div slot="item" slot-id="item" $each="this.state.list" $onclick="this.selectItem(item,index)" $class="list-group-item list-group-item-action list-group-item-heading d-inline-flex pe-auto align-items-baseline \${this.itemActiveTag?.(item)}">
<span $if="this.itemicon" class="bi bi-\${this.itemicon} text-body"></span>
<div class="flex-shrink-0 px-1" $text>\${item[this.labelfield]}</div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
<div slot-id="item-actions"></div>
</div>
</div>
`))
Component.register('GroupedList', container => {
Component.getSetupFunction('List')(container)
container._selectItem = container.selectItem
container.selectItem = (item, index) => { container.selectItem = (item, index) => {
container.state.selectedGroup = null if (container.hasAttribute('auto-select')) container.state.selectedItem = container.state.selectedItem === item[container.idfield] ? null : item[container.idfield]
container._selectItem(item, index) container.dispatchEvent(new CustomEvent('itemclick', { bubbles: false, detail: { item, index: index + (container.fast ? (container.state._listStartIndex || 0) : 0) } }))
} }
Component.attachSelectFeature(container, 'groupclick', 'selectGroup', 'groupActiveTag', 'selectedGroup', 'groupidfield', container.hasAttribute('auto-select-group') || container.hasAttribute('auto-select') && 'group-selectable')
container._selectGroup = container.selectGroup
container.selectGroup = (item, index) => { container.selectGroup = (item, index) => {
container.state.selectedItem = null if (container.hasAttribute('auto-select-group')) container.state.selectedGroup = container.state.selectedGroup === item[container.groupidfield] ? null : item[container.groupidfield]
container._selectGroup(item, index) container.dispatchEvent(new CustomEvent('groupclick', { bubbles: false, detail: { item, index } }))
} }
container.toggleCollapse = (item) => { if (container.collapsible && item._hasChildren) { container.collapsed[item[container.idfield]] = !container.collapsed[item[container.idfield]]; updateFlatList(); } }
const defaultSets = { typefield: 'type', groupidfield: 'id', grouplabelfield: 'label', groupsummaryfield: 'summary', groupfield: 'group' } updateFlatList()
container.state.__watch('list', oldList => {
Util.updateDefaults(container, defaultSets)
const newList = [];
(container.state.groups || []).forEach(group => {
newList.push({ [container.typefield]: 'group', ...group })
if (oldList) oldList.forEach(item => {
if (item[container.groupfield] === group[container.groupidfield]) {
newList.push({ [container.typefield]: 'item', ...item })
}
})
})
return newList
})
}, Util.makeDom(/*html*/` }, Util.makeDom(/*html*/`
<div class="list-group overflow-auto"> <div class="list-group overflow-auto" $onscroll="this.fast && this.refresh()" style="overflow-anchor:none">
<template $each="this.state.list"> <div $if="this.fast && (this.state?.prevHeight || 0) > 0" $style="height:\${this.state?.prevHeight}px;" class="flex-shrink-0"></div>
<div slot-id="group" $if="item.type === 'group'" $onclick="this.selectGroup(item,index)" style="height: 36px" $class="list-group-item list-group-item-action small d-inline-flex align-items-center ps-2 pe-2 \${this.groupActiveTag(item)}"> <template slot-id="item" $each="this.state?._renderedList">
<div $onupdate="this.onItemUpdate(index, thisNode)" $class="list-group-item list-group-item-action d-inline-flex align-items-center ps-2 pe-2 \${item.type==='group'?(this.state?.selectedGroup===item[this.groupidfield]?'active':''):(this.state?.selectedItem===item[this.idfield]?'active':'')}" $onclick="item.type==='group'?this.selectGroup(item,index):this.selectItem(item,index)">
<template $if="item.type === 'group'">
<span $if="this.groupicon" $class="bi bi-\${this.groupicon} text-body"></span> <span $if="this.groupicon" $class="bi bi-\${this.groupicon} text-body"></span>
<div class="flex-shrink-0 px-1" $text>\${item[this.grouplabelfield]}</div> <div class="flex-shrink-0 px-1" $text="\${item[this.grouplabelfield]}"></div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.groupsummaryfield]"></div> <div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="\${item[this.groupsummaryfield]}"></div>
<div slot-id="group-actions"></div> <div slot-id="group-actions"></div>
</template>
<template $if="item.type === 'item'">
<div $if="this.mode === 'tree'" $style="width:\${item._level * 16 + (this.collapsible ? 16 : 0)}px; cursor:\${this.collapsible ? 'pointer' : 'default'}" class="text-end text-muted flex-shrink-0" $onclick="event.stopPropagation(); this.toggleCollapse(item)">
<i $if="this.collapsible && item._hasChildren" $class="bi \${this.collapsed[item[this.idfield]] ? 'bi-caret-right-fill' : 'bi-caret-down-fill'}"></i>
</div> </div>
<div slot-id="item" $if="item.type === 'item'" $onclick="this.selectItem(item,index)" $class="list-group-item list-group-item-action d-inline-flex align-items-baseline ps-3 pe-2 \${this.itemActiveTag?.(item)}"> <span $if="this.mode === 'tree'" $class="text-muted bi bi-\${item._hasChildren ? this.groupicon : this.itemicon}"></span>
<span $if="this.itemicon" $class="bi bi-\${this.itemicon} text-body"></span> <span $if="this.mode !== 'tree' && this.itemicon" class="bi bi-\${this.itemicon} text-body"></span>
<div class="flex-shrink-0 px-1" $text>\${item[this.labelfield]}</div> <div class="flex-shrink-0 px-1" $text="\${item[this.labelfield]}"></div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div> <div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="\${item[this.summaryfield]}"></div>
<div slot-id="item-actions"></div> <div slot-id="item-actions"></div>
</template>
</div> </div>
</template> </template>
<div $if="this.fast && (this.state?.postHeight || 0) > 0" $style="height:\${this.state?.postHeight}px;" class="flex-shrink-0"></div>
</div> </div>
`)) `))
Component.register('FastGroupedList', container => {
Component.getSetupFunction('List')(container)
Component.getSetupFunction('GroupedList')(container)
container.refresh = () => container.querySelector('FastList')?.refresh()
}, Util.makeDom(/*html*/`
<FastList class="list-group" $.state.list="this.state.list">
<div slot="item">
<div slot-id="group" $if="item.type === 'group'" $onclick="this.selectGroup(item,index)" style="height: 36px" $class="list-group-item list-group-item-action small d-inline-flex align-items-center ps-2 pe-2 \${this.groupActiveTag(item)}">
<span $if="this.groupicon" $class="bi bi-\${this.groupicon} text-body"></span>
<div class="flex-shrink-0 px-1" $text>\${item[this.grouplabelfield]}</div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.groupsummaryfield]"></div>
<div slot-id="group-actions"></div>
</div>
<div slot-id="item" $if="item.type === 'item'" $onclick="this.selectItem(item,index)" $class="list-group-item list-group-item-action d-inline-flex align-items-baseline ps-3 pe-2 \${this.itemActiveTag?.(item)}">
<span $if="this.itemicon" $class="bi bi-\${this.itemicon} text-body"></span>
<div class="flex-shrink-0 px-1" $text>\${item[this.labelfield]}</div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
<div slot-id="item-actions"></div>
</div>
</div>
</FastList>
`))
Component.register('Tree', container => {
const defaultSets = { parentfield: 'parent', groupicon: 'folder', itemicon: 'file' }
container.state.__watch('list', () => {
Util.updateDefaults(container, defaultSets)
const childrenMap = {};
(container.state.list || []).forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
const newList = []
const traverse = (list, level) => list.forEach(item => {
const id = item[container.idfield]
const hasChildren = !!childrenMap[id]?.length
newList.push({ ...item, _level: level, _hasChildren: hasChildren })
if (hasChildren) traverse(childrenMap[id], level + 1)
})
traverse(childrenMap[''] || [], 0)
return newList
})
}, Util.makeDom(/*html*/`
<List>
<div slot="item" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
<div $style="width:\${item._level * 16}px" class="flex-shrink-0"></div>
<div $class="text-muted bi bi-\${item._hasChildren?this.groupicon:this.itemicon}"></div>
<div class="flex-grow-1 text-truncate px-2" $text="item[this.labelfield]"></div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
<div slot-id="item-actions"></div>
</div>
</List>
`))
export const FastTreeComponent = Component.register('FastTree', container => {
Component.getSetupFunction('List')(container)
Component.getSetupFunction('Tree')(container)
container.refresh = () => container.querySelector('FastList')?.refresh()
}, Util.makeDom(/*html*/`
<FastList class="list-group list-group-action" $.state.list="this.state.list">
<div slot="item" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
<div $style="width:\${item._level * 16}px" class="flex-shrink-0"></div>
<div $class="text-muted bi bi-\${item._hasChildren?this.groupicon:this.itemicon}"></div>
<div class="flex-grow-1 text-truncate px-2" $text="item[this.labelfield]"></div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
<div slot-id="item-actions"></div>
</div>
</FastList>
`))
Component.register('CollapseTree', container => {
Component.attachSelectFeature(container)
const defaultSets = { idfield: 'id', parentfield: 'parent', labelfield: 'label', summaryfield: 'summary' }
container.collapsed = NewState({})
container.state.__watch('list', () => {
Util.updateDefaults(container, defaultSets)
const childrenMap = {};
(container.state.list || []).forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
const newList = []
const traverse = (list, level, parents) => list.forEach(item => {
const id = item[container.idfield]
const hasChildren = !!childrenMap[id]?.length
newList.push({ ...item, _level: level, _hasChildren: hasChildren, _parents: parents })
if (hasChildren) traverse(childrenMap[id], level + 1, [...parents, id])
})
traverse(childrenMap[''] || [], 0, [])
return newList
})
}, Util.makeDom(/*html*/`
<List>
<div slot="item" slot-id="item" $if="!item._parents.some(cur=>this.collapsed[cur])" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
<div $style="width:\${item._level * 16 + 16}px; cursor: pointer;" class="text-end text-muted flex-shrink-0" $onclick="event.stopPropagation();this.collapsed[item[this.idfield]] = !this.collapsed[item[this.idfield]]">
<i $if="item._hasChildren" $class="bi \${this.collapsed[item[this.idfield]] ? 'bi-caret-right-fill' : 'bi-caret-down-fill'}"></i>
</div>
<div class="flex-grow-1 text-truncate px-1" $text="item[this.labelfield]"></div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
<div slot-id="item-actions"></div>
</div>
</List>
`))

View File

@ -67,6 +67,7 @@ export async function runTests() {
const time = performance.now() - start; const time = performance.now() - start;
window.benchResults[name] = time; window.benchResults[name] = time;
console.log(`BENCHMARK: ${name} scroll & refresh: ${time.toFixed(2)}ms`); console.log(`BENCHMARK: ${name} scroll & refresh: ${time.toFixed(2)}ms`);
console.log(`DEBUG [${id}]: _renderedList len: ${el.state._renderedList?.length}, _flatList len: ${el.state._flatList?.length}`);
}; };
await measure('ll', 'FastList'); await measure('ll', 'FastList');

View File

@ -28,17 +28,17 @@
} }
</script> </script>
<div class="d-flex flex-fill flex-wrap overflow-auto"> <div class="d-flex flex-fill flex-wrap overflow-auto">
<FastList id="ll" auto-select class="p-4 h-50 w-50 d-flex flex-column gap-3 bg-body-secondary rounded" $.state.list="list_data" $onitemclick="console.log(index, item)"> <List fast id="ll" auto-select class="p-4 h-50 w-50 d-flex flex-column gap-3 bg-body-secondary rounded" $.state.list="list_data" $onitemclick="console.log(index, item)">
<template slot="item"> <template slot="item">
<div class="d-flex justify-content-center align-items-center border border-primary rounded" $text="item.label" $.style.height="${(item.index%10)*5+40}px"></div> <div class="d-flex justify-content-center align-items-center border border-primary rounded" $text="item.label" $.style.height="${(item.index%10)*5+40}px"></div>
</template> </template>
</FastList> </List>
<FastGroupedList id="gl" auto-select auto-select-group class="p-4 h-50 w-50 d-flex flex-column border border-info rounded" $.state.groups="group_list" $.state.list="list_data" <List fast mode="group" id="gl" auto-select auto-select-group class="p-4 h-50 w-50 d-flex flex-column border border-info rounded" $.state.groups="group_list" $.state.list="list_data"
$ongroupclick="console.log(index, item)"> $ongroupclick="console.log(index, item)">
</FastGroupedList> </List>
<FastTree id="tt" auto-select class="p-4 h-50 w-50 d-flex flex-column border border-info rounded" $.state.list="list_data" $onitemclick="console.log(index, item)"></FastTree> <List fast mode="tree" id="tt" auto-select class="p-4 h-50 w-50 d-flex flex-column border border-info rounded" $.state.list="list_data" $onitemclick="console.log(index, item)"></List>
<CollapseTree id="ct" auto-select class="p-4 h-50 w-50 d-flex flex-column border border-info rounded" $.state.list="list_data.slice(0, 1200)" $onitemclick="console.log(index, item)"> <List mode="tree" collapsible id="ct" auto-select class="p-4 h-50 w-50 d-flex flex-column border border-info rounded" $.state.list="list_data.slice(0, 1200)" $onitemclick="console.log(index, item)">
</CollapseTree> </List>
</div> </div>
<script type="module"> <script type="module">