簡體   English   中英

如何為 Facebook Messenger 等聊天應用實現 React 無限滾動組件?

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

我知道關於這個主題有很多問題(React 無限滾動),我的問題旨在更深入地確定當前可用的最佳解決方案來實現這樣的組件。

我正在開發一個聊天應用程序,我創建了一個類似於 Facebook Messenger 聊天窗口的組件,您可以在桌面瀏覽器上看到它。

Facebook:

在此處輸入圖片說明

我的(到目前為止):

在此處輸入圖片說明 在此處輸入圖片說明

實現無限加載的無限滾動結果是很棘手的。 從用戶體驗的角度來看,我需要始終至少滿足以下屬性:

  1. 每行消息的高度應該實時動態計算,因為我不知道消息的高度,因為它們沒有固定的高度;
  2. 每當用戶鍵入新消息時,滾動條必須自動到達可滾動組件的底部,直到最后發送的消息。 可滾動組件本身具有頂部和底部填充(或者我也可以使用邊距),以便在聊天的頂部和第一條消息以及底部和最后一條消息之間留出一些空間(看上面的圖片);
  3. 聊天在一個彈出元素中,該元素以淡入動畫打開,用戶可以在使用頁面時關閉和打開它;

現在,為了做到這一點,我已經嘗試了幾個庫:

  • react-infinite :我的第一次嘗試,放棄了,因為它需要提前知道所有元素的高度;
  • react-list :我發現它真的很強大,問題是如果我關閉彈出窗口並在有時它丟失一些已經呈現的消息后重新打開它,在我看來這可能是react-list組件的錯誤。 此外,該組件不允許我向上顯示滾動底部(參見https://github.com/coderiety/react-list/issues/50 );
  • react-virtualized :非常強大,但我發現將ListInfiniteLoaderAutoSizerCellMeasurerCellMeasurerCache一起使用很棘手。 此外,當我發送消息時,如果我調用List.scrollToIndex(lastIndex)自動將容器滾動到底部,滾動不會完全到達底部,因為可滾動容器具有頂部和底部填充。 我無法使用此組件獲得令人滿意的結果。
  • react-infinite-any-height :我想試一試,但目前它似乎還沒有被移植到 React 16 如果我安裝它 NPM 警告我關於 React 15 的不滿意的對等依賴性,但我使用反應 16。

所以我的問題更像是一種相互對抗的方式:你們中的某個人是否曾經需要根據我上面寫的 3 個要求來實現一個 React 聊天組件? 你用的什么庫? 由於 Facebook Messenger 處理得很好並且他們使用 React,你們中有人知道他們是如何實現這樣的組件的嗎? 如果我檢查 Facebook 聊天窗口的聊天消息,它似乎將所有已呈現的消息保留在 DOM 中。 但是,如果是這樣,這不會影響性能嗎?

所以我現在的問題多於答案。 我真的很想找到一個適合我需要的組件。 另一種選擇是實現我自己的。

我最終實現了我自己的非常簡單的無限滾動組件(不過還沒有重構它以使用鈎子):


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>
        );
    }
}

this.props.children我將以下組件類的 React 元素數組傳遞給它,該類擴展了React.PureComponent

...

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

這樣,在重新渲染時,只會重新渲染自上次渲染以來發生更改的組件。

我還使用了一個不可變的數據結構來存儲聊天消息的集合,特別是immutable-linked-ordered-map ( https://github.com/tonix-tuft/immutable-linked-ordered-map ) 這讓我實現O(1)用於插入,刪除和消息以及幾乎更新的時間復雜度O(1)時間復雜度的查找。 本質上, 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


然后,當我渲染存儲在地圖中的消息時,我只需調用它的.values()方法,該方法返回一個數組並映射該數組以渲染消息,例如:


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

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM