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} - -
- + + + \ 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