diff --git a/components/dropdown/menu.ts b/components/dropdown/menu.ts
index ffefc4d91..24d522a2e 100644
--- a/components/dropdown/menu.ts
+++ b/components/dropdown/menu.ts
@@ -7,7 +7,9 @@ import {useMenuKeyboard} from './useKeyboard';
import {useMouseOutsidable} from '../../hooks/useMouseOutsidable';
import {FeedbackCallback} from './usePosition';
-export interface DropdownMenuProps { }
+export interface DropdownMenuProps {
+ virtualList?: boolean | string
+}
export interface DropdownMenuEvents { }
export interface DropdownMenuBlocks { }
diff --git a/components/dropdown/menu.vdt b/components/dropdown/menu.vdt
index d32147911..809df5cb0 100644
--- a/components/dropdown/menu.vdt
+++ b/components/dropdown/menu.vdt
@@ -1,8 +1,9 @@
import {Transition} from 'intact';
import {makeMenuStyles} from './styles';
+import {Virtual} from '../virtualList';
const {value, trigger, container} = this.dropdown.get();
-const {children, className} = this.get();
+const {children, className, virtualList} = this.get();
const classNameObj = {
'k-dropdown-menu': true,
@@ -18,14 +19,20 @@ const transition = $props.transition || {css: false, ...this.transition};
appear={true}
{...transition}
>
-
-
- {children}
-
-
-
+
+
+ {children}
+
+
+
+
\ No newline at end of file
diff --git a/components/select/demos/virtualList.md b/components/select/demos/virtualList.md
new file mode 100644
index 000000000..adc497cf8
--- /dev/null
+++ b/components/select/demos/virtualList.md
@@ -0,0 +1,47 @@
+---
+title: 虚拟列表
+order: 12
+---
+
+给`Select`添加`virtualList`属性可以在`Option`过多时保证页面的性能,`virtualList`的值为true时开启,为false时关闭,也可以设置为`"auto"`,在`Option`数量超过200时则自动开启虚拟列表功能。
+
+```vdt
+import {Select, Option} from 'kpc';
+
+
+
+ You selected: {this.get('day')}
+
+```
+
+```ts
+interface Props {
+ day?: string | null
+}
+
+const list = [];
+for(let i = 0;i < 2000; i++) {
+ list.push({
+ title: `item - ${i}`,
+ id: i
+ })
+}
+
+export default class extends Component {
+ static template = template;
+ static defaults() {
+ return {
+ day: 100,
+ list: list
+ };
+ }
+}
+```
+
+
+```styl
+.k-select-option
+ width: 280px;
+```
diff --git a/components/select/index.md b/components/select/index.md
index 4d66202d2..a82d07f44 100644
--- a/components/select/index.md
+++ b/components/select/index.md
@@ -31,6 +31,7 @@ sidebar: doc
| labelMap | 建立值`value`到展示标签`label`的映射,可以在`value`不在`Option`集合中时,依然能够正确展示相应的`label` | `Map` | `new Map()` |
| card | 是否展示`card`模式 | `boolean` | `false` |
| autoDisableArrow | 是否在没有更多可选项时,给箭头一个`disabled`状态来提示用户 | `boolean` | `false` |
+| virtualList | 是否开启虚拟列表功能 | `boolean` | `"auto"` | `false` |
```ts
export type Container = string | ((parentDom: Element, anchor: Node | null) => Element)
diff --git a/components/select/menu.vdt b/components/select/menu.vdt
index 7967f16e1..63b253dd2 100644
--- a/components/select/menu.vdt
+++ b/components/select/menu.vdt
@@ -11,7 +11,7 @@ import {context} from './useSearchable';
import {Tabs, Tab} from '../tabs';
let {children, className} = this.get();
-const {card, searchable, multiple} = this.select.get();
+const {card, searchable, multiple, virtualList} = this.select.get();
const classNameObj = {
'k-select-menu': true,
@@ -86,6 +86,6 @@ if (searchable) {
);
}
-
+
{children}
diff --git a/components/select/select.ts b/components/select/select.ts
index 9b68d761f..12dcf6f9e 100644
--- a/components/select/select.ts
+++ b/components/select/select.ts
@@ -21,6 +21,7 @@ export interface SelectProps exte
labelMap?: Map
card?: boolean
autoDisableArrow?: boolean
+ virtualList?: boolean | string
}
export interface SelectEvents extends BaseSelectEvents { }
@@ -37,6 +38,7 @@ const typeDefs: Required> = {
labelMap: Map,
card: Boolean,
autoDisableArrow: Boolean,
+ virtualList: Boolean,
};
const defaults = (): Partial => ({
diff --git a/components/virtualList/index.ts b/components/virtualList/index.ts
new file mode 100644
index 000000000..52cc28222
--- /dev/null
+++ b/components/virtualList/index.ts
@@ -0,0 +1,124 @@
+import {
+ Component,
+ TypeDefs,
+ VNode,
+ Children,
+ createVNode as h,
+ className as getClassName,
+ normalizeChildren
+} from 'intact';
+import {useSize} from './useSize';
+import {useRange} from './useRange';
+import {useScroll} from './useScroll';
+
+export interface VirtualProps {
+ keeps?: number
+ buffer?: number
+ estimateSize?: number
+ total: number
+ isFixedType?: boolean
+ nativeProps?: any
+ start: number
+ end: number
+ enableVirtualList?: boolean | string
+}
+
+export type RangeInfo = {
+ start: number
+ end: number
+ paddingTop: number
+ paddingBottom: number
+}
+
+const typeDefs: Required> = {
+ keeps: Number,
+ buffer: Number,
+ estimateSize: Number,
+ total: Number,
+ isFixedType: Boolean,
+ nativeProps: Object,
+ start: Number,
+ end: Number,
+ enableVirtualList: [Boolean, String]
+};
+
+const defaults = (): Partial => ({
+ keeps: 30,
+ buffer: 10
+});
+
+export class Virtual extends Component {
+ static typeDefs = typeDefs;
+ static defaults = defaults;
+ static template = function(this: Virtual) {
+ const children: Children = this.get('children');
+ const nativeProps = this.$vNode.props?.nativeProps;
+ const enableVirtualList = this.get('enableVirtualList');
+
+ if(nativeProps.class) {
+ nativeProps.class = getClassName(nativeProps.class);
+ }
+
+ const auto = enableVirtualList === 'auto' && this.normalizedVnodes.length < 200;
+ if(!enableVirtualList || auto) {
+ return h('div', nativeProps, children);
+ }
+
+ const outerAttrs = {
+ ...nativeProps,
+ 'ev-scroll': (e: WheelEvent) => this.scroll.handleScroll((e.target as HTMLElement).scrollTop)
+ }
+ const {start, end} = this.get();
+ const {paddingTop, paddingBottom} = this.range.getRange();
+ const innerAttrs = {style: `padding-top: ${paddingTop}px; padding-bottom: ${paddingBottom}px`};
+ const croppedChildren = this.normalizedVnodes.slice(start, end + 1);
+ const vnode = this.vnode = h('div', outerAttrs, h('div', innerAttrs, croppedChildren));
+
+ return vnode;
+ };
+
+ vnode: VNode | null = null;
+ offset: number = 0;
+
+ private normalizedVnodes: VNode[] = [];
+
+ rangeInfo: RangeInfo = {
+ start: 0,
+ end: 0,
+ paddingTop: 0,
+ paddingBottom: 0
+ };
+
+ private size = useSize();
+ private range = useRange(this.size);
+ private scroll = useScroll(this.size, this.range);
+
+ init() {
+ if(!this.get('enableVirtualList')) return;
+
+ const keeps = this.get('keeps')!;
+ const children = this.get('children') as VNode;
+
+ this.set('start', 0);
+ this.set('end', keeps - 1);
+
+ if(Array.isArray(children) && children.length == 2) {
+ this.set('total', children[1].length);
+ }
+
+ this.watch('children', (newValue) => {
+ const fakeVNode = {} as VNode;
+ normalizeChildren(fakeVNode, newValue);
+ this.normalizedVnodes = fakeVNode.children as VNode[];
+ })
+ }
+
+ mounted() {
+ if(!this.get('enableVirtualList')) return;
+ this.size.setItemSize();
+ }
+
+ initItems(): void {
+ this.range.checkRange(0, this.size.getEndByStart(0));
+ }
+}
\ No newline at end of file
diff --git a/components/virtualList/useRange.ts b/components/virtualList/useRange.ts
new file mode 100644
index 000000000..57f5baaa1
--- /dev/null
+++ b/components/virtualList/useRange.ts
@@ -0,0 +1,68 @@
+import {useInstance, nextTick} from 'intact';
+import {Virtual} from './index';
+import {Size} from './useSize';
+
+type RangeInfo = {
+ start: number
+ end: number
+ paddingTop: number
+ paddingBottom: number
+}
+
+export type Range = {
+ checkRange: (start: number, end: number) => void
+ getRange: () => RangeInfo
+}
+
+export function useRange(size: Size): Range {
+ const instance = useInstance() as Virtual;
+
+ function getRange(): RangeInfo {
+ const range: RangeInfo = Object.create(null);
+ const tmpRange = instance.rangeInfo;
+ range.start = tmpRange.start;
+ range.end = tmpRange.end;
+ range.paddingTop = tmpRange.paddingTop;
+ range.paddingBottom = tmpRange.paddingBottom;
+
+ return range;
+ }
+
+ function checkRange(start: number, end: number): void {
+ const keeps = instance.get('keeps')!;
+ const total = instance.get('total');
+
+ // Render all if total <= keeps
+ if(total <= keeps) {
+ start = 0;
+ end = size.getLastIndex();
+ } else if(end - start < keeps - 1) {
+ start = end - keeps + 1;
+ }
+
+ updateRange(start, end);
+ }
+
+ function setRange(): void {
+ const {start, end} = getRange();
+ instance.set('start', start);
+ instance.set('end', end);
+
+ nextTick(size.setItemSize.bind(instance));
+ }
+
+ function updateRange(start: number, end: number): void {
+ const tmp = instance.rangeInfo;
+ tmp.start = start;
+ tmp.end = end;
+ tmp.paddingTop = size.getPadFront();
+ tmp.paddingBottom = size.getPadBehind();
+
+ setRange();
+ }
+
+ return {
+ checkRange,
+ getRange
+ }
+}
\ No newline at end of file
diff --git a/components/virtualList/useScroll.ts b/components/virtualList/useScroll.ts
new file mode 100644
index 000000000..abda2a20e
--- /dev/null
+++ b/components/virtualList/useScroll.ts
@@ -0,0 +1,49 @@
+import {useInstance} from 'intact';
+import {Virtual} from './index';
+import {Size} from './useSize';
+import {Range} from './useRange';
+
+type ScrollObj = {
+ handleScroll: (offset: number) => void
+}
+
+export function useScroll(size: Size, range: Range): ScrollObj {
+ const instance = useInstance() as Virtual;
+
+ function handleScroll(offset: number): void {
+ const isDown = offset > instance.offset;
+ instance.offset = offset;
+
+ if(isDown) {
+ handleBehind();
+ } else {
+ handleFront();
+ }
+ }
+
+ function handleBehind(): void {
+ const overs = size.getPassedItems();
+
+ // Range should not change if scroll overs within buffer
+ if(overs < instance.rangeInfo.start + instance.get('buffer')!) {
+ return;
+ }
+
+ range.checkRange(overs, size.getEndByStart(overs));
+ }
+
+ function handleFront(): void {
+ const overs = size.getPassedItems();
+
+ if(overs > instance.rangeInfo.start) {
+ return;
+ }
+
+ const start = Math.max(overs - instance.get('buffer')!, 0);
+ range.checkRange(start, size.getEndByStart(start));
+ }
+
+ return {
+ handleScroll
+ }
+}
\ No newline at end of file
diff --git a/components/virtualList/useSize.ts b/components/virtualList/useSize.ts
new file mode 100644
index 000000000..358d4fdc1
--- /dev/null
+++ b/components/virtualList/useSize.ts
@@ -0,0 +1,169 @@
+import {useInstance} from 'intact';
+import {Virtual} from './index';
+
+export type Size = {
+ getLastIndex: () => number
+ setItemSize: () => void
+ getPadFront: () => number
+ getPadBehind: () => number
+ getPassedItems: () => number
+ getEndByStart: (start: number) => number
+}
+
+const CALC_TYPE = {
+ INIT: Symbol('init'),
+ FIXED: Symbol('fixed'),
+ DYNAMIC: Symbol('dynamic')
+};
+
+export function useSize(): Size {
+ const instance = useInstance() as Virtual;
+ const sizes: Map = new Map();
+
+ let calcType = CALC_TYPE.INIT;
+ let firstRangeTotalSize = 0;
+ let firstRangeAverageSize = 0;
+ let renderedIndex = 0;
+ let fixedSizeValue = 0;
+ let hasComputedAll: boolean | undefined = undefined;
+
+ function isFixedType(): boolean {
+ const {isFixedType} = instance.get();
+ return isFixedType !== undefined
+ ? isFixedType
+ : calcType === CALC_TYPE.FIXED;
+ }
+
+ function getEstimateSize(): number {
+ return isFixedType()
+ ? fixedSizeValue
+ : (instance.get('estimateSize') ?? firstRangeAverageSize);
+ }
+
+ function getLastIndex(): number {
+ return instance.get('total') - 1;
+ }
+
+ function getEndByStart(start: number): number {
+ const theoreticalEnd = start + instance.get('keeps')! - 1;
+ return Math.min(theoreticalEnd, getLastIndex());
+ }
+
+ function saveSize(itemIndex: number, size: number): void {
+ const {keeps, total} = instance.get();
+ sizes.set(itemIndex, size);
+
+ if(calcType === CALC_TYPE.INIT) {
+ fixedSizeValue = size;
+ calcType = CALC_TYPE.FIXED;
+ } else if(calcType === CALC_TYPE.FIXED && fixedSizeValue !== size) {
+ calcType = CALC_TYPE.DYNAMIC;
+ }
+
+ // Computed average size of item when render first group of list
+ if(sizes.size < Math.min(keeps!, total)) {
+ firstRangeTotalSize = [...sizes.values()].reduce((p, c) => p + c, 0);
+ firstRangeAverageSize = Math.round(firstRangeTotalSize / sizes.size);
+ }
+
+ // Set initial size of item
+ if(++renderedIndex == keeps) {
+ instance.initItems();
+ }
+ }
+
+ function setItemSize(): void {
+ const dom = instance.vnode?.dom;
+ const items = (dom?.firstChild as HTMLDivElement).children;
+ const start = instance.get('start');
+
+ for(let i = 0;i < items.length; i++) {
+ const item = items[i];
+ const size = (item as HTMLDivElement).offsetHeight;
+ const index = start + i;
+
+ if(sizes.get(index) == undefined) {
+ saveSize(index, size);
+ }
+ }
+ }
+
+ function getIndexOffset(givenIndex: number): number {
+ if(!givenIndex) return 0;
+
+ let offset = 0;
+ let indexSize = 0;
+ for(let index = 0; index < givenIndex; index++) {
+ indexSize = sizes.get(index)!;
+ offset = offset + (typeof indexSize === 'number' ? indexSize : getEstimateSize());
+ }
+
+ return offset;
+ }
+
+ function getPadFront(): number {
+ if(isFixedType()) {
+ return fixedSizeValue * instance.rangeInfo.start;
+ } else {
+ return getIndexOffset(instance.rangeInfo.start);
+ }
+ }
+
+ function getPadBehind(): number {
+ const end = instance.rangeInfo.end;
+ const lastIndex = getLastIndex();
+
+ if(isFixedType()) {
+ return (lastIndex - end) * fixedSizeValue;
+ }
+
+ if(end === lastIndex && hasComputedAll === undefined) {
+ hasComputedAll = true;
+ }
+
+ if(hasComputedAll) {
+ // If all item has been computed, return an actual size
+ return getIndexOffset(lastIndex) - getIndexOffset(end);
+ } else {
+ // Return a estimated value before all items has computed
+ return (lastIndex - end) * getEstimateSize();
+ }
+ }
+
+ function getPassedItems(): number {
+ if(isFixedType()) {
+ return Math.floor(instance.offset / fixedSizeValue);
+ }
+
+ // If item's size is dynamic, get passed items according
+ // to current scroll offset and size of item in sizes
+ let low = 0;
+ let middle = 0;
+ let high = instance.get('total');
+ let middleOffset = 0;
+
+ while(low <= high) {
+ middle = low + Math.floor((high - low) / 2);
+ middleOffset = getIndexOffset(middle);
+
+ if(middleOffset === instance.offset) {
+ return middle;
+ } else if(middleOffset < instance.offset) {
+ low = middle + 1;
+ } else if(middleOffset > instance.offset) {
+ high = middle - 1;
+ }
+ }
+
+ return low > 0 ? --low : 0;
+ }
+
+ return {
+ getLastIndex,
+ setItemSize,
+ getPadFront,
+ getPadBehind,
+ getPassedItems,
+ getEndByStart
+ }
+}
\ No newline at end of file