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

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

@ -3,7 +3,7 @@ import { useMemoizedFn } from 'ahooks';
type Size = { height: number; width: number }; type Size = { height: number; width: number };
interface ResizeWatcherProps { interface ResizeWatcherProps extends React.PropsWithChildren {
wrapperStyle?: React.CSSProperties; wrapperStyle?: React.CSSProperties;
onResize?: (size: Size) => void; onResize?: (size: Size) => void;
} }
@ -34,6 +34,7 @@ export const ResizeWatcher: React.FC<ResizeWatcherProps> = React.memo(
return; return;
} }
// 使用 contentRect 计算大小以确保不会出现使用clientHeight立即向浏览器请求dom大小导致的性能问题
handleResize({ handleResize({
width: Math.round(contentRect.width), width: Math.round(contentRect.width),
height: Math.round(contentRect.height), 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 { ResizeWatcher } from './ResizeWatcher';
import { useMemoizedFn, useDebounceFn, usePrevious } from 'ahooks'; import { useMemoizedFn, useDebounceFn, usePrevious } from 'ahooks';
import { IsForward, Vector } from './types'; import type { IsForward, Vector } from './types';
import clsx from 'clsx'; import clsx from 'clsx';
export interface ScrollerRef { export interface ScrollerRef {
scrollTo: (position: Vector) => void; scrollTo: (position: Vector) => void;
getMaxPosition: () => Vector; scrollToBottom: () => void;
} }
type ScrollerProps = PropsWithChildren<{ type ScrollerProps = PropsWithChildren<{
@ -15,6 +21,7 @@ type ScrollerProps = PropsWithChildren<{
innerStyle?: React.CSSProperties; innerStyle?: React.CSSProperties;
isLock?: boolean; isLock?: boolean;
scrollingClassName?: string; scrollingClassName?: string;
scrollBehavior?: ScrollBehavior;
onScroll?: ( onScroll?: (
position: Vector, position: Vector,
detail: { detail: {
@ -37,27 +44,16 @@ const DEFAULT_POS = { x: 0, y: 0 };
*/ */
export const Scroller = React.forwardRef<ScrollerRef, ScrollerProps>( export const Scroller = React.forwardRef<ScrollerRef, ScrollerProps>(
(props, ref) => { (props, ref) => {
const { scrollBehavior = 'auto' } = props;
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null); const innerRef = useRef<HTMLDivElement>(null);
const style = useMemo(() => { const style = useMemo(() => {
if (props.isLock ?? false) { if (props.isLock ?? false) {
return { ...props.style, overflow: 'hidden' }; return { ...props.style, overflow: 'hidden' };
} }
return props.style;
}, []);
const [isScroll, setIsScroll] = useState(false); return { ...props.style, overflow: 'auto' };
const [isMouseDown, setIsMouseDown] = useState(false); }, [props.isLock]);
const { run: setIsScrollLazy } = useDebounceFn(
(val) => {
setIsScroll(val);
},
{
leading: false,
trailing: true,
wait: 300,
}
);
const getPosition = useMemoizedFn(() => { const getPosition = useMemoizedFn(() => {
if (!wrapperRef.current) { 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( const { run: handleEndScrollLazy } = useDebounceFn(
() => { () => {
setIsScroll(false); setIsScroll(false);
@ -107,7 +133,7 @@ export const Scroller = React.forwardRef<ScrollerRef, ScrollerProps>(
}); });
const prevPosition = usePrevious(getPosition()) ?? DEFAULT_POS; const prevPosition = usePrevious(getPosition()) ?? DEFAULT_POS;
const handleMouseScroll = useMemoizedFn(() => { const handleScroll = useMemoizedFn(() => {
const isUserScrolling = isScroll || isMouseDown; const isUserScrolling = isScroll || isMouseDown;
const currentPosition = getPosition(); const currentPosition = getPosition();
const forward = { const forward = {
@ -138,7 +164,7 @@ export const Scroller = React.forwardRef<ScrollerRef, ScrollerProps>(
<ResizeWatcher wrapperStyle={{ height: '100%' }} onResize={handleResize}> <ResizeWatcher wrapperStyle={{ height: '100%' }} onResize={handleResize}>
<div <div
key="scroller" key="scroller"
className={clsx(props.className, { className={clsx(props.className, 'scroller', {
[props.scrollingClassName ?? 'scrolling']: isScroll, [props.scrollingClassName ?? 'scrolling']: isScroll,
})} })}
style={style} style={style}
@ -146,11 +172,11 @@ export const Scroller = React.forwardRef<ScrollerRef, ScrollerProps>(
onWheel={handleWheel} onWheel={handleWheel}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onScroll={handleMouseScroll} onScroll={handleScroll}
> >
<div <div
className="scroller_content" className="scroller-inner"
key="scroller_content" key="scroller-inner"
style={props.innerStyle} style={props.innerStyle}
ref={innerRef} 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 { 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 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 ( return (
<Scroller ref={scrollerRef}> <div className="virtual-chat-list" style={style}>
{/* TODO */} <Scroller ref={scrollerRef} style={scrollerStyle} innerStyle={innerStyle}>
<div>Foo</div> {props.items.map((item, i) => (
</Scroller> <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'; VirtualChatList.displayName = 'VirtualChatList';

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

@ -55,7 +55,7 @@
"react-transition-group": "^4.4.2", "react-transition-group": "^4.4.2",
"react-use-gesture": "^9.1.3", "react-use-gesture": "^9.1.3",
"react-virtualized-auto-sizer": "^1.0.6", "react-virtualized-auto-sizer": "^1.0.6",
"react-virtuoso": "^2.18.0", "react-virtuoso": "^2.19.1",
"socket.io-client": "^4.1.2", "socket.io-client": "^4.1.2",
"source-ref-runtime": "^1.0.7", "source-ref-runtime": "^1.0.7",
"styled-components": "^5.3.6", "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 { buildMessageItemRow } from './Item';
import type { MessageListProps } from './types'; import type { MessageListProps } from './types';
import { import {
@ -8,7 +8,6 @@ import {
} from 'react-virtuoso'; } from 'react-virtuoso';
import { import {
ChatMessage, ChatMessage,
sharedEvent,
useMemoizedFn, useMemoizedFn,
useSharedEventHandler, useSharedEventHandler,
} from 'tailchat-shared'; } from 'tailchat-shared';
@ -73,25 +72,27 @@ export const VirtualizedMessageList: React.FC<MessageListProps> = React.memo(
}); });
return ( return (
<Virtuoso <div className="flex-1">
style={virtuosoStyle} <Virtuoso
ref={listRef} style={virtuosoStyle}
firstItemIndex={PREPEND_OFFSET - numItemsPrepended} ref={listRef}
initialTopMostItemIndex={Math.max(props.messages.length - 1, 0)} firstItemIndex={PREPEND_OFFSET - numItemsPrepended}
computeItemKey={computeItemKey} initialTopMostItemIndex={Math.max(props.messages.length - 1, 0)}
totalCount={props.messages.length} computeItemKey={computeItemKey}
overscan={{ totalCount={props.messages.length}
main: 450, overscan={{
reverse: 450, main: 1000,
}} reverse: 1000,
itemContent={itemContent} }}
alignToBottom={true} itemContent={itemContent}
startReached={handleLoadMore} alignToBottom={true}
followOutput={followOutput} startReached={handleLoadMore}
defaultItemHeight={25} followOutput={followOutput}
atTopThreshold={100} defaultItemHeight={25}
atBottomThreshold={40} 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 ? ( return useVirtualizedList ? (
<div className="flex-1"> <VirtualizedMessageList {...props} />
<VirtualizedMessageList {...props} />
</div>
) : ( ) : (
<NormalMessageList {...props} /> <NormalMessageList {...props} />
); );

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

Loading…
Cancel
Save