refactor: 迭代自实现虚拟列表并升级react-virtuoso

pull/56/head
moonrailgun 2 years ago
parent f038d08af8
commit 9500a2fab0

@ -3,7 +3,7 @@ import { useMemoizedFn } from 'ahooks';
type Size = { height: number; width: number };
interface ResizeWatcherProps {
interface ResizeWatcherProps extends React.PropsWithChildren {
wrapperStyle?: React.CSSProperties;
onResize?: (size: Size) => void;
}
@ -34,6 +34,7 @@ export const ResizeWatcher: React.FC<ResizeWatcherProps> = React.memo(
return;
}
// 使用 contentRect 计算大小以确保不会出现使用clientHeight立即向浏览器请求dom大小导致的性能问题
handleResize({
width: Math.round(contentRect.width),
height: Math.round(contentRect.height),

@ -1,12 +1,18 @@
import React, { PropsWithChildren, useMemo, useRef, useState } from 'react';
import React, {
PropsWithChildren,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { ResizeWatcher } from './ResizeWatcher';
import { useMemoizedFn, useDebounceFn, usePrevious } from 'ahooks';
import { IsForward, Vector } from './types';
import type { IsForward, Vector } from './types';
import clsx from 'clsx';
export interface ScrollerRef {
scrollTo: (position: Vector) => void;
getMaxPosition: () => Vector;
scrollToBottom: () => void;
}
type ScrollerProps = PropsWithChildren<{
@ -15,6 +21,7 @@ type ScrollerProps = PropsWithChildren<{
innerStyle?: React.CSSProperties;
isLock?: boolean;
scrollingClassName?: string;
scrollBehavior?: ScrollBehavior;
onScroll?: (
position: Vector,
detail: {
@ -37,27 +44,16 @@ 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;
}, []);
const [isScroll, setIsScroll] = useState(false);
const [isMouseDown, setIsMouseDown] = useState(false);
const { run: setIsScrollLazy } = useDebounceFn(
(val) => {
setIsScroll(val);
},
{
leading: false,
trailing: true,
wait: 300,
}
);
return { ...props.style, overflow: 'auto' };
}, [props.isLock]);
const getPosition = useMemoizedFn(() => {
if (!wrapperRef.current) {
@ -79,6 +75,36 @@ export const Scroller = React.forwardRef<ScrollerRef, ScrollerProps>(
};
});
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);
@ -107,7 +133,7 @@ export const Scroller = React.forwardRef<ScrollerRef, ScrollerProps>(
});
const prevPosition = usePrevious(getPosition()) ?? DEFAULT_POS;
const handleMouseScroll = useMemoizedFn(() => {
const handleScroll = useMemoizedFn(() => {
const isUserScrolling = isScroll || isMouseDown;
const currentPosition = getPosition();
const forward = {
@ -138,7 +164,7 @@ export const Scroller = React.forwardRef<ScrollerRef, ScrollerProps>(
<ResizeWatcher wrapperStyle={{ height: '100%' }} onResize={handleResize}>
<div
key="scroller"
className={clsx(props.className, {
className={clsx(props.className, 'scroller', {
[props.scrollingClassName ?? 'scrolling']: isScroll,
})}
style={style}
@ -146,11 +172,11 @@ export const Scroller = React.forwardRef<ScrollerRef, ScrollerProps>(
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onScroll={handleMouseScroll}
onScroll={handleScroll}
>
<div
className="scroller_content"
key="scroller_content"
className="scroller-inner"
key="scroller-inner"
style={props.innerStyle}
ref={innerRef}
>

@ -1,14 +1,82 @@
import React, { useRef } from 'react';
import React, { useEffect, useMemo, useRef } from 'react';
import { ResizeWatcher } from './ResizeWatcher';
import { Scroller, ScrollerRef } from './Scroller';
import { useUpdate } from 'ahooks';
export const VirtualChatList: React.FC = React.memo(() => {
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 (
<Scroller ref={scrollerRef}>
{/* TODO */}
<div>Foo</div>
</Scroller>
<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';

@ -7,6 +7,7 @@ export { Highlight } from './Highlight';
export { Icon } from './Icon';
export { Image } from './Image';
export { SensitiveText } from './SensitiveText';
export { VirtualChatList } from './VirtualChatList';
export { WebMetaForm } from './WebMetaForm';
export {

@ -55,7 +55,7 @@
"react-transition-group": "^4.4.2",
"react-use-gesture": "^9.1.3",
"react-virtualized-auto-sizer": "^1.0.6",
"react-virtuoso": "^2.18.0",
"react-virtuoso": "^2.19.1",
"socket.io-client": "^4.1.2",
"source-ref-runtime": "^1.0.7",
"styled-components": "^5.3.6",

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef } from 'react';
import React, { useMemo, useRef } from 'react';
import { buildMessageItemRow } from './Item';
import type { MessageListProps } from './types';
import {
@ -8,7 +8,6 @@ import {
} from 'react-virtuoso';
import {
ChatMessage,
sharedEvent,
useMemoizedFn,
useSharedEventHandler,
} from 'tailchat-shared';
@ -73,25 +72,27 @@ export const VirtualizedMessageList: React.FC<MessageListProps> = React.memo(
});
return (
<Virtuoso
style={virtuosoStyle}
ref={listRef}
firstItemIndex={PREPEND_OFFSET - numItemsPrepended}
initialTopMostItemIndex={Math.max(props.messages.length - 1, 0)}
computeItemKey={computeItemKey}
totalCount={props.messages.length}
overscan={{
main: 450,
reverse: 450,
}}
itemContent={itemContent}
alignToBottom={true}
startReached={handleLoadMore}
followOutput={followOutput}
defaultItemHeight={25}
atTopThreshold={100}
atBottomThreshold={40}
/>
<div className="flex-1">
<Virtuoso
style={virtuosoStyle}
ref={listRef}
firstItemIndex={PREPEND_OFFSET - numItemsPrepended}
initialTopMostItemIndex={Math.max(props.messages.length - 1, 0)}
computeItemKey={computeItemKey}
totalCount={props.messages.length}
overscan={{
main: 1000,
reverse: 1000,
}}
itemContent={itemContent}
alignToBottom={true}
startReached={handleLoadMore}
followOutput={followOutput}
defaultItemHeight={25}
atTopThreshold={100}
atBottomThreshold={40}
/>
</div>
);
}
);

@ -0,0 +1,55 @@
import React, { useCallback } from 'react';
import { VirtualChatList } from 'tailchat-design';
import { ChatMessage, useMemoizedFn } from 'tailchat-shared';
import { buildMessageItemRow } from './Item';
import type { MessageListProps } from './types';
/**
* WIP:
*
*/
const style: React.CSSProperties = {
// height: '100%',
flex: 1,
};
export const VirtualizedMessageList: React.FC<MessageListProps> = React.memo(
(props) => {
// useSharedEventHandler('sendMessage', () => {
// listRef.current?.scrollToIndex({
// index: 'LAST',
// align: 'end',
// behavior: 'smooth',
// });
// });
// const handleLoadMore = useMemoizedFn(() => {
// if (props.isLoadingMore) {
// return;
// }
// if (props.hasMoreMessage) {
// props.onLoadMore();
// }
// });
const itemContent = useMemoizedFn((item: ChatMessage, index: number) => {
return buildMessageItemRow(props.messages, index);
});
const getItemKey = useCallback((item: ChatMessage) => {
return String(item._id);
}, []);
return (
<VirtualChatList
style={style}
items={props.messages}
itemContent={itemContent}
getItemKey={getItemKey}
/>
);
}
);
VirtualizedMessageList.displayName = 'VirtualizedMessageList';

@ -21,9 +21,7 @@ export const ChatMessageList: React.FC<MessageListProps> = React.memo(
}
return useVirtualizedList ? (
<div className="flex-1">
<VirtualizedMessageList {...props} />
</div>
<VirtualizedMessageList {...props} />
) : (
<NormalMessageList {...props} />
);

@ -371,7 +371,7 @@ importers:
react-transition-group: ^4.4.2
react-use-gesture: ^9.1.3
react-virtualized-auto-sizer: ^1.0.6
react-virtuoso: ^2.18.0
react-virtuoso: ^2.19.1
rimraf: ^3.0.2
rollup-plugin-copy: ^3.4.0
rollup-plugin-replace: ^2.2.0
@ -434,7 +434,7 @@ importers:
react-transition-group: 4.4.5_biqbaboplfbrettd7655fr4n2y
react-use-gesture: 9.1.3_react@18.2.0
react-virtualized-auto-sizer: 1.0.6_biqbaboplfbrettd7655fr4n2y
react-virtuoso: 2.18.0_biqbaboplfbrettd7655fr4n2y
react-virtuoso: 2.19.1_biqbaboplfbrettd7655fr4n2y
socket.io-client: 4.5.1
source-ref-runtime: 1.0.7
styled-components: 5.3.6_7i5myeigehqah43i5u7wbekgba
@ -11474,7 +11474,7 @@ packages:
babel-plugin-syntax-jsx: 6.18.0
lodash: 4.17.21
picomatch: 2.3.1
styled-components: 5.3.6_mdz3marskokvq6744hhidi3r5a
styled-components: 5.3.6_7i5myeigehqah43i5u7wbekgba
/babel-plugin-syntax-jsx/6.18.0:
resolution: {integrity: sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==}
@ -26472,6 +26472,7 @@ packages:
prop-types: 15.8.1
react: 16.14.0
scheduler: 0.19.1
dev: false
/react-dom/17.0.2_react@17.0.2:
resolution: {integrity: sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==}
@ -27171,8 +27172,8 @@ packages:
react-dom: 18.2.0_react@18.2.0
dev: false
/react-virtuoso/2.18.0_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-BxMW9as2dPOP4YFkry/oNjQMEn3cOgEjHLb7Fg8oubOgRAfiukp1Co41QFD9ZMXZBNBZNTI2E5BwC5pol31mTg==}
/react-virtuoso/2.19.1_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-zF6MAwujNGy2nJWCx/Df92ay/RnV2Kj4glUZfdyadI4suAn0kAZHB1BeI7yPFVp2iSccLzFlszhakWyr+fJ4Dw==}
engines: {node: '>=10'}
peerDependencies:
react: '>=16 || >=17 || >= 18'
@ -27191,6 +27192,7 @@ packages:
loose-envify: 1.4.0
object-assign: 4.1.1
prop-types: 15.8.1
dev: false
/react/17.0.2:
resolution: {integrity: sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==}
@ -28273,6 +28275,7 @@ packages:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
dev: false
/scheduler/0.20.2:
resolution: {integrity: sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==}
@ -29452,7 +29455,6 @@ packages:
react-is: 18.2.0
shallowequal: 1.1.0
supports-color: 5.5.0
dev: false
/styled-components/5.3.6_mdz3marskokvq6744hhidi3r5a:
resolution: {integrity: sha512-hGTZquGAaTqhGWldX7hhfzjnIYBZ0IXQXkCYdvF1Sq3DsUaLx6+NTHC5Jj1ooM2F68sBiVz3lvhfwQs/S3l6qg==}
@ -29476,6 +29478,7 @@ packages:
react-is: 16.13.1
shallowequal: 1.1.0
supports-color: 5.5.0
dev: false
/styled-components/5.3.6_react@18.2.0:
resolution: {integrity: sha512-hGTZquGAaTqhGWldX7hhfzjnIYBZ0IXQXkCYdvF1Sq3DsUaLx6+NTHC5Jj1ooM2F68sBiVz3lvhfwQs/S3l6qg==}

Loading…
Cancel
Save