简体   繁体   English

如何为 Facebook Messenger 等聊天应用实现 React 无限滚动组件?

[英]How to implement a React infinite scroll component for a chat app like Facebook Messenger?

I know there are many questions about this topic (React infinite scroll), my question aims to go more in-depth in order to identify the best currently available solution to implement such a component.我知道关于这个主题有很多问题(React 无限滚动),我的问题旨在更深入地确定当前可用的最佳解决方案来实现这样的组件。

I am working on a chat app and I have created a component similar to the Facebook's Messenger chat window which you can see on desktop browsers.我正在开发一个聊天应用程序,我创建了一个类似于 Facebook Messenger 聊天窗口的组件,您可以在桌面浏览器上看到它。

Facebook: Facebook:

在此处输入图片说明

Mine (so far):我的(到目前为止):

在此处输入图片说明 在此处输入图片说明

Implementing the infinite scroll with infinite loading turns out to be tricky.实现无限加载的无限滚动结果是很棘手的。 From a UX perspective, I need to always satisfy at least the following properties:从用户体验的角度来看,我需要始终至少满足以下属性:

  1. The height of each row message should be dynamically computed just-in-time because I do not know the height of the message in advance as they do not have a fixed height;每行消息的高度应该实时动态计算,因为我不知道消息的高度,因为它们没有固定的高度;
  2. Whenever a user types a new message, the scroll must automatically reach the bottom of the scrollable component to the last just sent message.每当用户键入新消息时,滚动条必须自动到达可滚动组件的底部,直到最后发送的消息。 The scrollable component itself has a top and bottom padding (or I can also use a margin) in order to leave some space between the top and the first and the bottom and the last message of the chat (look at the above images);可滚动组件本身具有顶部和底部填充(或者我也可以使用边距),以便在聊天的顶部和第一条消息以及底部和最后一条消息之间留出一些空间(看上面的图片);
  3. The chat is inside a popover element which opens with a fade-in animation and it can be closed and opened by the user while they are using the page;聊天在一个弹出元素中,该元素以淡入动画打开,用户可以在使用页面时关闭和打开它;

Now, in order to do that, I have already tried several libraries:现在,为了做到这一点,我已经尝试了几个库:

  • react-infinite : my first attempt, abandoned because it needs to know the heights of all the elements in advance; react-infinite :我的第一次尝试,放弃了,因为它需要提前知道所有元素的高度;
  • react-list : I found it really powerful, the thing is that if I close my popover and reopen it after sometimes it loses some already rendered messages and it seems to me that it could be a bug of the react-list component. react-list :我发现它真的很强大,问题是如果我关闭弹出窗口并在有时它丢失一些已经呈现的消息后重新打开它,在我看来这可能是react-list组件的错误。 Also, the component does not allow me to display the scrolling bottom upwards (see https://github.com/coderiety/react-list/issues/50 );此外,该组件不允许我向上显示滚动底部(参见https://github.com/coderiety/react-list/issues/50 );
  • react-virtualized : very powerful, but I found it tricky to use List with an InfiniteLoader together with AutoSizer , CellMeasurer and CellMeasurerCache . react-virtualized :非常强大,但我发现将ListInfiniteLoaderAutoSizerCellMeasurerCellMeasurerCache一起使用很棘手。 Also, as I send a message if I call List.scrollToIndex(lastIndex) to scroll automatically the container to the bottom the scroll does not reach the bottom completely, as the scrollable container has top and bottom padding.此外,当我发送消息时,如果我调用List.scrollToIndex(lastIndex)自动将容器滚动到底部,滚动不会完全到达底部,因为可滚动容器具有顶部和底部填充。 I couldn't achieve a satisfiable result with this component.我无法使用此组件获得令人满意的结果。
  • react-infinite-any-height : I would like to give it a try, but currently it seems that it hasn't been ported to React 16 yet if I install it NPM warns me about an unsatisfied peer dependency of React 15, but I use React 16. react-infinite-any-height :我想试一试,但目前它似乎还没有被移植到 React 16 如果我安装它 NPM 警告我关于 React 15 的不满意的对等依赖性,但我使用反应 16。

So my question is more a way to confront each other: have someone of you ever had to implement a React chat component with the 3 requirements I have written above?所以我的问题更像是一种相互对抗的方式:你们中的某个人是否曾经需要根据我上面写的 3 个要求来实现一个 React 聊天组件? What library did you use?你用的什么库? As Facebook Messenger handles this pretty well and they use React, do someone of you know how did they implement such a component?由于 Facebook Messenger 处理得很好并且他们使用 React,你们中有人知道他们是如何实现这样的组件的吗? If I inspect the chat messages of the Facebook chat window it seems that it keeps all the already rendered messages in the DOM.如果我检查 Facebook 聊天窗口的聊天消息,它似乎将所有已呈现的消息保留在 DOM 中。 But, if so, couldn't this affect performance?但是,如果是这样,这不会影响性能吗?

So I have more questions than answers for now.所以我现在的问题多于答案。 I would really like to find a component that suits my needs.我真的很想找到一个适合我需要的组件。 The other option would be to implement my own.另一种选择是实现我自己的。

I ended up implementing my own very simple infinite scroll component (didn't refactor it to use hooks yet, though):我最终实现了我自己的非常简单的无限滚动组件(不过还没有重构它以使用钩子):


import React from "react";
import {
    isUndefined,
    hasVerticalScrollbar,
    hasHorizontalScrollbar,
    isInt,
    debounce
} from "js-utl";
import { classNames } from "react-js-utl/utils";

export default class SimpleInfiniteScroll extends React.Component {
    constructor(props) {
        super(props);

        this.handleScroll = this.handleScroll.bind(this);
        this.onScrollStop = debounce(this.onScrollStop.bind(this), 100);

        this.itemsIdsRefsMap = {};
        this.isLoading = false;
        this.isScrolling = false;
        this.lastScrollStopPromise = null;
        this.lastScrollStopPromiseResolve = null;

        this.node = React.createRef();
    }

    componentDidMount() {
        this.scrollToStart();
    }

    getNode() {
        return this.node && this.node.current;
    }

    getSnapshotBeforeUpdate(prevProps) {
        if (prevProps.children.length < this.props.children.length) {
            const list = this.node.current;
            const axis = this.axis();
            const scrollDimProperty = this.scrollDimProperty(axis);
            const scrollProperty = this.scrollProperty(axis);
            const scrollDelta = list[scrollDimProperty] - list[scrollProperty];

            return {
                scrollDelta
            };
        }
        return null;
    }

    componentDidUpdate(prevProps, prevState, snapshot) {
        if (
            this.isLoading &&
            ((prevProps.isInfiniteLoading && !this.props.isInfiniteLoading) ||
                ((this.props.hasMore || prevProps.hasMore) &&
                    prevProps.children.length !==
                        this.props.children.length)) &&
            snapshot
        ) {
            if (this.props.displayInverse) {
                const list = this.node.current;
                const axis = this.axis();
                const scrollDimProperty = this.scrollDimProperty(axis);
                const scrollProperty = this.scrollProperty(axis);
                const scrollDelta = snapshot.scrollDelta;
                const scrollTo = list[scrollDimProperty] - scrollDelta;

                this.scrollTo(scrollProperty, scrollTo);
            }
            this.isLoading = false;
        }
    }

    loadingComponentRenderer() {
        const { loadingComponent } = this.props;

        return (
            <div
                className="simple-infinite-scroll-loading-component"
                key={-2}
            >
                {loadingComponent}
            </div>
        );
    }

    axis() {
        return this.props.axis === "x" ? "x" : "y";
    }

    scrollProperty(axis) {
        return axis === "y" ? "scrollTop" : "scrollLeft";
    }

    offsetProperty(axis) {
        return axis === "y" ? "offsetHeight" : "offsetWidth";
    }

    clientDimProperty(axis) {
        return axis === "y" ? "clientHeight" : "clientWidth";
    }

    scrollDimProperty(axis) {
        return axis === "y" ? "scrollHeight" : "scrollWidth";
    }

    hasScrollbarFunction(axis) {
        return axis === "y" ? hasVerticalScrollbar : hasHorizontalScrollbar;
    }

    scrollToStart() {
        const axis = this.axis();
        this.scrollTo(
            this.scrollProperty(axis),
            !this.props.displayInverse ? 0 : this.scrollDimProperty(axis)
        );
    }

    scrollToEnd() {
        const axis = this.axis();
        this.scrollTo(
            this.scrollProperty(axis),
            !this.props.displayInverse ? this.scrollDimProperty(axis) : 0
        );
    }

    scrollTo(scrollProperty, scrollPositionOrPropertyOfScrollable) {
        const scrollableContentNode = this.node.current;
        if (scrollableContentNode) {
            scrollableContentNode[scrollProperty] = isInt(
                scrollPositionOrPropertyOfScrollable
            )
                ? scrollPositionOrPropertyOfScrollable
                : scrollableContentNode[scrollPositionOrPropertyOfScrollable];
        }
    }

    scrollToId(id) {
        if (this.itemsIdsRefsMap[id] && this.itemsIdsRefsMap[id].current) {
            this.itemsIdsRefsMap[id].current.scrollIntoView();
        }
    }

    scrollStopPromise() {
        return (
            (this.isScrolling && this.lastScrollStopPromise) ||
            Promise.resolve()
        );
    }

    onScrollStop(callback) {
        callback();
        this.isScrolling = false;
        this.lastScrollStopPromise = null;
        this.lastScrollStopPromiseResolve = null;
    }

    handleScroll(e) {
        const {
            isInfiniteLoading,
            hasMore,
            infiniteLoadBeginEdgeOffset,
            displayInverse
        } = this.props;

        this.isScrolling = true;
        this.lastScrollStopPromise =
            this.lastScrollStopPromise ||
            new Promise(resolve => {
                this.lastScrollStopPromiseResolve = resolve;
            });
        this.onScrollStop(() => {
            this.lastScrollStopPromiseResolve &&
                this.lastScrollStopPromiseResolve();
        });

        this.props.onScroll && this.props.onScroll(e);

        if (
            this.props.onInfiniteLoad &&
            (!isUndefined(hasMore) ? hasMore : !isInfiniteLoading) &&
            this.node.current &&
            !this.isLoading
        ) {
            const axis = this.axis();
            const scrollableContentNode = this.node.current;
            const scrollProperty = this.scrollProperty(axis);
            const offsetProperty = this.offsetProperty(axis);
            const scrollDimProperty = this.scrollDimProperty(axis);
            const currentScroll = scrollableContentNode[scrollProperty];
            const currentDim = scrollableContentNode[offsetProperty];
            const scrollDim = scrollableContentNode[scrollDimProperty];

            const finalInfiniteLoadBeginEdgeOffset = !isUndefined(
                infiniteLoadBeginEdgeOffset
            )
                ? infiniteLoadBeginEdgeOffset
                : currentDim / 2;

            let thresoldWasReached = false;
            if (!displayInverse) {
                const clientDimProperty = this.clientDimProperty(axis);
                const clientDim = scrollableContentNode[clientDimProperty];
                thresoldWasReached =
                    currentScroll +
                        clientDim +
                        finalInfiniteLoadBeginEdgeOffset >=
                    scrollDim;
            } else {
                thresoldWasReached =
                    currentScroll <= finalInfiniteLoadBeginEdgeOffset;
            }
            if (thresoldWasReached) {
                this.isLoading = true;
                this.props.onInfiniteLoad();
            }
        }
    }

    render() {
        const {
            children,
            displayInverse,
            isInfiniteLoading,
            className,
            hasMore
        } = this.props;

        return (
            <div
                className={classNames("simple-infinite-scroll", className)}
                ref={this.node}
                onScroll={this.handleScroll}
                onMouseOver={this.props.onInfiniteScrollMouseOver}
                onMouseOut={this.props.onInfiniteScrollMouseOut}
                onMouseEnter={this.props.onInfiniteScrollMouseEnter}
                onMouseLeave={this.props.onInfiniteScrollMouseLeave}
            >
                {(hasMore || isInfiniteLoading) &&
                    displayInverse &&
                    this.loadingComponentRenderer()}
                {children}
                {(hasMore || isInfiniteLoading) &&
                    !displayInverse &&
                    this.loadingComponentRenderer()}
            </div>
        );
    }
}

And in this.props.children I pass it an array of React elements of the following component's class which extends React.PureComponent :this.props.children我将以下组件类的 React 元素数组传递给它,该类扩展了React.PureComponent

...

export default class ChatMessage extends React.PureComponent {
    ...
}

This way, when re-rendering, only the components that have changed since the last render are re-rendered.这样,在重新渲染时,只会重新渲染自上次渲染以来发生更改的组件。

I have also used an immutable data structure to store the collection of the chat messages, in particularly immutable-linked-ordered-map ( https://github.com/tonix-tuft/immutable-linked-ordered-map ) which allows me to achieve O(1) time complexity for insertions, removals and updates of a message as well as almost O(1) time complexity for lookups.我还使用了一个不可变的数据结构来存储聊天消息的集合,特别是immutable-linked-ordered-map ( https://github.com/tonix-tuft/immutable-linked-ordered-map ) 这让我实现O(1)用于插入,删除和消息以及几乎更新的时间复杂度O(1)时间复杂度的查找。 Essentially, ImmutableLinkedOrderedMap is an ordered immutable map, like associative arrays in PHP, but immutable:本质上, ImmutableLinkedOrderedMap是一个有序的不可变映射,就像 PHP 中的关联数组一样,但不可变:


const map = new ImmutableLinkedOrderedMap({
    mode: ImmutableLinkedOrderedMap.MODE.MULTIWAY,
    initialItems: [
        {
            id: 1, // <--- "[keyPropName] === 'id'"
            text: "Message text",
            // ...
        },
        {
            id: 2,
            text: "Another message text",
            // ...
        },
        // ...
    ]
})
map.get(2) // Will return: { id: 2, text: "Another message text", /* ... */ }
const newMessage = { id: 3, text: "Yet another message text", /* ... */ };
const newMap = map.set(newMessage);

console.log(map !== newMap); // true
console.log(map.length); // 2
console.log(newMap.length); // 3

let messages = newMap.replace(3, newMessage)
console.log(messages === newMap); // true, because newMessage with ID 3 didn't change
messages = newMap.replace(3, { ...newMessage, read: true })
console.log(messages === newMap); // false


Then, when I render the messages stored in the map, I simply call its .values() method which returns an array and I map that array to render the messages, eg:然后,当我渲染存储在地图中的消息时,我只需调用它的.values()方法,该方法返回一个数组并映射该数组以渲染消息,例如:


<SimpleInfiniteScroll>
    {messages.values().map((message) => <ChatMessage ... />)}
</SimpleInfiniteScroll>

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM