优化
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
WIP: Warning
|
||||
|
||||
可变高度的虚拟列表
|
||||
|
||||
专为聊天场景打造的
|
||||
@@ -0,0 +1,61 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
|
||||
type Size = { height: number; width: number };
|
||||
|
||||
interface ResizeWatcherProps extends React.PropsWithChildren {
|
||||
wrapperStyle?: React.CSSProperties;
|
||||
onResize?: (size: Size) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当容器大小发生变化时
|
||||
* 触发回调
|
||||
*/
|
||||
export const ResizeWatcher: React.FC<ResizeWatcherProps> = React.memo(
|
||||
(props) => {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleResize = useMemoizedFn((size: Size) => {
|
||||
if (props.onResize) {
|
||||
props.onResize(size);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!rootRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const { target, contentRect } = entry;
|
||||
if (!target.parentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 contentRect 计算大小以确保不会出现使用clientHeight立即向浏览器请求dom大小导致的性能问题
|
||||
handleResize({
|
||||
width: Math.round(contentRect.width),
|
||||
height: Math.round(contentRect.height),
|
||||
});
|
||||
});
|
||||
});
|
||||
resizeObserver.observe(rootRef.current);
|
||||
|
||||
return () => {
|
||||
if (resizeObserver && rootRef.current) {
|
||||
resizeObserver.unobserve(rootRef.current);
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={props.wrapperStyle} ref={rootRef}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
ResizeWatcher.displayName = 'ResizeWatcher';
|
||||
190
client/packages/design/components/VirtualChatList/Scroller.tsx
Normal file
190
client/packages/design/components/VirtualChatList/Scroller.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { ResizeWatcher } from './ResizeWatcher';
|
||||
import { useMemoizedFn, useDebounceFn, usePrevious } from 'ahooks';
|
||||
import type { IsForward, Vector } from './types';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface ScrollerRef {
|
||||
scrollTo: (position: Vector) => void;
|
||||
scrollToBottom: () => void;
|
||||
}
|
||||
|
||||
type ScrollerProps = PropsWithChildren<{
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
innerStyle?: React.CSSProperties;
|
||||
isLock?: boolean;
|
||||
scrollingClassName?: string;
|
||||
scrollBehavior?: ScrollBehavior;
|
||||
onScroll?: (
|
||||
position: Vector,
|
||||
detail: {
|
||||
forward: IsForward;
|
||||
isUserScrolling: boolean;
|
||||
isMouseDown: boolean;
|
||||
}
|
||||
) => void;
|
||||
onScrollEnd?: (position: Vector) => void;
|
||||
onContainerResize?: (info: {
|
||||
containerSize: Vector;
|
||||
position: Vector;
|
||||
}) => void;
|
||||
}>;
|
||||
|
||||
const DEFAULT_POS = { x: 0, y: 0 };
|
||||
|
||||
/**
|
||||
* 滚动状态管理组件
|
||||
*/
|
||||
export const Scroller = React.forwardRef<ScrollerRef, ScrollerProps>(
|
||||
(props, ref) => {
|
||||
const { scrollBehavior = 'auto' } = props;
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const style = useMemo(() => {
|
||||
if (props.isLock ?? false) {
|
||||
return { ...props.style, overflow: 'hidden' };
|
||||
}
|
||||
|
||||
return { ...props.style, overflow: 'auto' };
|
||||
}, [props.isLock]);
|
||||
|
||||
const getPosition = useMemoizedFn(() => {
|
||||
if (!wrapperRef.current) {
|
||||
return DEFAULT_POS;
|
||||
}
|
||||
return {
|
||||
x: wrapperRef.current.scrollLeft,
|
||||
y: wrapperRef.current.scrollTop,
|
||||
};
|
||||
});
|
||||
|
||||
const getContainerSize = useMemoizedFn(() => {
|
||||
if (!wrapperRef.current) {
|
||||
return DEFAULT_POS;
|
||||
}
|
||||
return {
|
||||
x: wrapperRef.current.clientWidth,
|
||||
y: wrapperRef.current.clientHeight,
|
||||
};
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollTo: (position) => {
|
||||
wrapperRef.current?.scrollTo({
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
behavior: scrollBehavior,
|
||||
});
|
||||
},
|
||||
scrollToBottom: () => {
|
||||
wrapperRef.current?.scrollTo({
|
||||
left: getPosition().x,
|
||||
top: wrapperRef.current.scrollHeight - getContainerSize().y,
|
||||
behavior: scrollBehavior,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const [isScroll, setIsScroll] = useState(false);
|
||||
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||
const { run: setIsScrollLazy } = useDebounceFn(
|
||||
(val) => {
|
||||
setIsScroll(val);
|
||||
},
|
||||
{
|
||||
leading: false,
|
||||
trailing: true,
|
||||
wait: 300,
|
||||
}
|
||||
);
|
||||
|
||||
const { run: handleEndScrollLazy } = useDebounceFn(
|
||||
() => {
|
||||
setIsScroll(false);
|
||||
if (props.onScrollEnd) {
|
||||
props.onScrollEnd(getPosition());
|
||||
}
|
||||
},
|
||||
{
|
||||
leading: false,
|
||||
trailing: true,
|
||||
wait: 300,
|
||||
}
|
||||
);
|
||||
|
||||
const handleWheel = useMemoizedFn(() => {
|
||||
setIsScroll(true);
|
||||
setIsScrollLazy(false);
|
||||
});
|
||||
|
||||
const handleMouseDown = useMemoizedFn(() => {
|
||||
setIsMouseDown(true);
|
||||
});
|
||||
|
||||
const handleMouseUp = useMemoizedFn(() => {
|
||||
setIsMouseDown(false);
|
||||
});
|
||||
|
||||
const prevPosition = usePrevious(getPosition()) ?? DEFAULT_POS;
|
||||
const handleScroll = useMemoizedFn(() => {
|
||||
const isUserScrolling = isScroll || isMouseDown;
|
||||
const currentPosition = getPosition();
|
||||
const forward = {
|
||||
x: currentPosition.x > prevPosition.x,
|
||||
y: currentPosition.y > prevPosition.y,
|
||||
};
|
||||
setIsScroll(true);
|
||||
handleEndScrollLazy();
|
||||
if (props.onScroll) {
|
||||
props.onScroll(currentPosition, {
|
||||
forward,
|
||||
isUserScrolling,
|
||||
isMouseDown: isMouseDown,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleResize = useMemoizedFn(() => {
|
||||
if (props.onContainerResize) {
|
||||
props.onContainerResize({
|
||||
containerSize: getContainerSize(),
|
||||
position: getPosition(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<ResizeWatcher wrapperStyle={{ height: '100%' }} onResize={handleResize}>
|
||||
<div
|
||||
key="scroller"
|
||||
className={clsx(props.className, 'scroller', {
|
||||
[props.scrollingClassName ?? 'scrolling']: isScroll,
|
||||
})}
|
||||
style={style}
|
||||
ref={wrapperRef}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div
|
||||
className="scroller-inner"
|
||||
key="scroller-inner"
|
||||
style={props.innerStyle}
|
||||
ref={innerRef}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
</ResizeWatcher>
|
||||
);
|
||||
}
|
||||
);
|
||||
Scroller.displayName = 'Scroller';
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import { VirtualChatList } from '.';
|
||||
|
||||
// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
|
||||
export default {
|
||||
title: 'Tailchat/VirtualChatList',
|
||||
component: VirtualChatList,
|
||||
// More on argTypes: https://storybook.js.org/docs/react/api/argtypes
|
||||
argTypes: {},
|
||||
} as ComponentMeta<typeof VirtualChatList>;
|
||||
|
||||
// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
|
||||
const Template: ComponentStory<typeof VirtualChatList> = (args) => (
|
||||
<VirtualChatList {...args} />
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
// More on args: https://storybook.js.org/docs/react/writing-stories/args
|
||||
Default.args = {
|
||||
text: 'fooooo',
|
||||
};
|
||||
82
client/packages/design/components/VirtualChatList/index.tsx
Normal file
82
client/packages/design/components/VirtualChatList/index.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { ResizeWatcher } from './ResizeWatcher';
|
||||
import { Scroller, ScrollerRef } from './Scroller';
|
||||
import { useUpdate } from 'ahooks';
|
||||
|
||||
interface VirtualChatListProps<ItemType> {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
innerStyle?: React.CSSProperties;
|
||||
getItemKey?: (item: ItemType) => string;
|
||||
items: ItemType[];
|
||||
itemContent: (item: ItemType, index: number) => React.ReactNode;
|
||||
}
|
||||
|
||||
const defaultContainerStyle: React.CSSProperties = {
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const defaultInnerStyle: React.CSSProperties = {
|
||||
height: '100%',
|
||||
};
|
||||
|
||||
const scrollerStyle: React.CSSProperties = {
|
||||
height: '100%',
|
||||
};
|
||||
|
||||
const InternalVirtualChatList = <ItemType extends object>(
|
||||
props: VirtualChatListProps<ItemType>
|
||||
) => {
|
||||
const scrollerRef = useRef<ScrollerRef>(null);
|
||||
const itemHeightCache = useMemo(() => new Map<ItemType, number>(), []);
|
||||
const forceUpdate = useUpdate();
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
...defaultContainerStyle,
|
||||
...props.style,
|
||||
}),
|
||||
[props.style]
|
||||
);
|
||||
const innerStyle = useMemo(
|
||||
() => ({
|
||||
...defaultInnerStyle,
|
||||
...props.innerStyle,
|
||||
}),
|
||||
[props.innerStyle]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// 挂载后滚动到底部
|
||||
scrollerRef.current?.scrollToBottom();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="virtual-chat-list" style={style}>
|
||||
<Scroller ref={scrollerRef} style={scrollerStyle} innerStyle={innerStyle}>
|
||||
{props.items.map((item, i) => (
|
||||
<div
|
||||
key={props.getItemKey ? props.getItemKey(item) : i}
|
||||
className="virtual-chat-list__item"
|
||||
style={{ height: itemHeightCache.get(item) }}
|
||||
>
|
||||
<ResizeWatcher
|
||||
onResize={(size) => {
|
||||
itemHeightCache.set(item, size.height);
|
||||
forceUpdate();
|
||||
}}
|
||||
>
|
||||
{props.itemContent(item, i)}
|
||||
</ResizeWatcher>
|
||||
</div>
|
||||
))}
|
||||
</Scroller>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type VirtualChatListInterface = typeof InternalVirtualChatList & React.FC;
|
||||
|
||||
export const VirtualChatList: VirtualChatListInterface = React.memo(
|
||||
InternalVirtualChatList
|
||||
) as any;
|
||||
VirtualChatList.displayName = 'VirtualChatList';
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface Vector {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface IsForward {
|
||||
x: boolean;
|
||||
y: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user