简体   繁体   中英

React infinite scroll component performance

I have written the following infinite scroll component in React:

import React from 'react'
import { uniqueId, isUndefined, hasVerticalScrollbar, hasHorizontalScrollbar, isInt, throttle } from '../../../js/utils';

export default class BlSimpleInfiniteScroll extends React.Component {

    constructor(props) {
        super(props)

        this.handleScroll = this.handleScroll.bind(this)

        this.itemsIdsRefsMap = {}

        this.isLoading = false

        this.node = React.createRef()
    }

    componentDidMount() {
        const {
            initialId
        } = this.props
        let id
        if (initialId) {
            if (typeof initialId === "function") {
                id = initialId()
            }
            else {
                id = initialId
            }
            this.scrollToId(id)   
        }
    }

    componentDidUpdate(prevProps) {
        if (
            this.isLoading
            &&
            prevProps.isInfiniteLoading
            &&
            !this.props.isInfiniteLoading
        ) {
            const axis = this.axis()
            const scrollProperty = this.scrollProperty(axis)
            const offsetProperty = this.offsetProperty(axis)
            this.scrollTo(scrollProperty, this.node.current[offsetProperty])
            this.isLoading = false
        }
    }

    itemsRenderer(items) {
        const length = items.length

        let i = 0
        const renderedItems = []
        for (const item of items) {
            renderedItems[i] = this.itemRenderer(item.id, i, length)
            i++
        }
        return renderedItems
    }

    itemRenderer(id, i, length) {
        const {
            itemRenderer,
            isInfiniteLoading,
            displayInverse
        } = this.props

        let renderedItem = itemRenderer(id, i)
        if (isInfiniteLoading) {
            if (!displayInverse && (i == length - 1)) {
                renderedItem = this.standardLoadingComponentWrapperRenderer(id, renderedItem)
            }
            else if (i == 0) {
                renderedItem = this.inverseLoadingComponentWrapperRenderer(id, renderedItem)
            }
        }
        const ref = this.itemsIdsRefsMap[id] || (this.itemsIdsRefsMap[id] = React.createRef())
        return (
            <div className="bl-simple-infinite-scroll-item"
                key={id}
                ref={ref}>
                {renderedItem}
            </div>
        )
    }

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

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

    loadingComponentWrapperRenderer(id, children) {
        return (
            <div className="bl-simple-infinite-scroll-loading-component-wrapper"
                key={id}>
                {children}
            </div>
        )
    }

    standardLoadingComponentWrapperRenderer(id, renderedItem) {
        return this.loadingComponentWrapperRenderer(id, [
            renderedItem,
            this.loadingComponentRenderer()
        ])
    }

    inverseLoadingComponentWrapperRenderer(id, renderedItem) {
        return this.loadingComponentWrapperRenderer(id, [
            this.loadingComponentRenderer(),
            renderedItem
        ])
    }

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

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

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

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

    handleScroll() {
        const {
            isInfiniteLoading,
            infiniteLoadBeginEdgeOffset,
            displayInverse
        } = this.props
        if (
            this.props.onInfiniteLoad
            &&
            !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
            let memorizeLastElementBeforeInfiniteLoad = () => { }
            if (!displayInverse) {
                thresoldWasReached = currentScroll >= (scrollDim - finalInfiniteLoadBeginEdgeOffset)
            }
            else {
                memorizeLastElementBeforeInfiniteLoad = () => {
                    // TODO
                }
                thresoldWasReached = currentScroll <= finalInfiniteLoadBeginEdgeOffset
            }
            if (thresoldWasReached) {
                this.isLoading = true
                memorizeLastElementBeforeInfiniteLoad()
                this.props.onInfiniteLoad()
            }
        }
    }

    render() {
        const {
            items
        } = this.props

        return (
            <div className="bl-simple-infinite-scroll"
                ref={this.node}
                onScroll={this.handleScroll}
                onMouseOver={this.props.onInfiniteScrollMouseOver}
                onMouseOut={this.props.onInfiniteScrollMouseOut}
                onMouseEnter={this.props.onInfiniteScrollMouseEnter}
                onMouseLeave={this.props.onInfiniteScrollMouseLeave}>
                {this.itemsRenderer(items)}
            </div>
        )
    }

}

And I use it like this in a chat app I am writing:

...
<BlSimpleInfiniteScroll items={chat.messages}
    ref={this.infiniteScrollComponentRef}
    initialId={() => lastOfArray(chat.messages).id}
    itemRenderer={(id, i) => this.messageRenderer(id, i, chat.messages)}
    loadingComponent={<BlLoadingSpinnerContainer />}
    isInfiniteLoading={isChatLoading}
    displayInverse
    infiniteLoadBeginEdgeOffset={void 0}
    infiniteLoadingBeginBottomOffset={void 0}
    onInfiniteLoad={() => this.props.onLoadPreviousChatMessages(chat.id)}
    onInfiniteScrollMouseEnter={this.handleInfiniteScrollMouseEnter}
    onInfiniteScrollMouseLeave={this.handleInfiniteScrollMouseLeave} />
...

The problem is that as soon as I scroll until the thresold and onInfiniteLoad is called, before the loading spinner is showed and after the data has been loaded the scroll freezes and the component becomes unresponsive. How can I resolve this issue? When I render the spinner container and after the previous loaded messages, shouldn't React just append the new divs retaining the previously added items in order to maintain the component performant? If not, what key concepts of React I am missing?

Thank you for your attention!

UPDATE : Here are the additional components:

BlOrderChat represents a chat window and renders BlSimpleInfiniteScroll :

import React from 'react'
import BlOrderChatMessage from './BlOrderChatMessage';
import { isEmpty, uniqueId } from '../../../js/utils';
import { chatSelector } from '../selectors';
import BlLoadingSpinnerContainer from '../../core/animation/loading/BlLoadingSpinnerContainer';
import BlSimpleInfiniteScroll from '../../core/scroll/BlSimpleInfiniteScroll';

export default class BlOrderChat extends React.Component {

    static BL_ORDER_CHAT_MESSAGE_ID_ATTR_PREFIX = 'blOrderChatMessage'

    constructor(props) {
        super(props)

        this.messageRenderer = this.messageRenderer.bind(this)

        this.infiniteScrollComponentRef = React.createRef()
    }

    scrollToBottom() {
        this.infiniteScrollComponentRef.current && this.infiniteScrollComponentRef.current.scrollToStart()
    }

    messageRenderer(messageId, index, messages) {
        const {
            currentUser, chat
        } = this.props
        const message = messages[index]
        const length = messages.length
        const fromUser = chat.users.items[message.from_user_id]

        const itemComponentRender = (children) => (
            <div key={messageId}>
                {children}
            </div>
        )

        const messageIdAttr = `${BlOrderChat.BL_ORDER_CHAT_MESSAGE_ID_ATTR_PREFIX}${message.id}`
        const renderMessageComponent = () => (
            <BlOrderChatMessage id={messageIdAttr}
                key={uniqueId() + message.id}
                message={message.message}
                sentUnixTs={message.sent_unix_ts}
                currentUser={currentUser}
                fromUser={fromUser}
                usersInvolvedInChatLength={chat.users.order.length} />
        )
        let children = []
        if (index === 0) {
            // First message.
            children = [
                <div key={uniqueId()} className="bl-padding"></div>,
                renderMessageComponent()
            ]
        }
        else if (index === length - 1) {
            // Last message.
            children = [
                renderMessageComponent(onComponentDidMount),
                <div key={uniqueId()} className="bl-padding"></div>
            ]
        }
        else {
            // Message in the middle.
            children = [
                renderMessageComponent()
            ]
        }
        return itemComponentRender(children)
    }

    render() {
        const {
            chat: propsChat, isChatLoading,
            currentUser
        } = this.props

        const chat = chatSelector(propsChat, currentUser)
        const chatHasMessages = chat && !isEmpty(chat.messages)

        return (
            <div className="bl-order-chat">
                <div className="bl-order-chat-header">
                    // ...
                </div>
                <div className="bl-order-chat-content">
                    {
                        (chatHasMessages &&
                            <div className="bl-order-chat-content-inner">
                                <div className="bl-order-chat-infinite-scroll">
                                    <BlSimpleInfiniteScroll items={chat.messages}
                                        ref={this.infiniteScrollComponentRef}
                                        initialId={() => lastOfArray(chat.messages).id}
                                        itemRenderer={(id, i) => this.messageRenderer(id, i, chat.messages)}
                                        loadingComponent={<BlLoadingSpinnerContainer />}
                                        isInfiniteLoading={isChatLoading}
                                        displayInverse
                                        infiniteLoadBeginEdgeOffset={void 0}
                                        infiniteLoadingBeginBottomOffset={void 0}
                                        onInfiniteLoad={() => this.props.onLoadPreviousChatMessages(chat.id)}
                                        onInfiniteScrollMouseEnter={this.handleInfiniteScrollMouseEnter}
                                        onInfiniteScrollMouseLeave={this.handleInfiniteScrollMouseLeave} />
                                </div>
                            </div>
                        )
                        ||
                        (isChatLoading &&
                            <BlLoadingSpinnerContainer />
                        )
                    }
                </div>
                <div className="bl-order-chat-footer">
                    // ...
                </div>
            </div>
        )
    }
}

BlOrderChatBox , contains BlOrderChat :

import React from 'react'
import BlOrderChat from './BlOrderChat';
import BlAlert from '../../core/alert/BlAlert';
import BlLoadingSpinnerContainer from '../../core/animation/loading/BlLoadingSpinnerContainer';

export default class BlOrderChatBox extends React.Component {

    constructor(props) {
        super(props)

        this.node = React.createRef()
    }

    render() {
        const {
            ordId, currentChat,
            isCurrentChatLoading, currentUser,
            err
        } = this.props

        return (
            <div className="bl-order-chat-box" ref={this.node}>
                <div className="bl-order-chat-box-inner">
                    {
                        (err &&
                            <BlAlert type="error" message={err} />)
                        ||
                        (currentChat && (
                            // ...
                            <div className="bl-order-chat-box-inner-chat-content">
                                <BlOrderChat ordId={ordId}
                                    chat={currentChat}
                                    isChatLoading={isCurrentChatLoading}
                                    onLoadPreviousChatMessages={this.props.onLoadPreviousChatMessages}
                                    currentUser={currentUser} />
                            </div>
                        ))
                        ||
                        <BlLoadingSpinnerContainer />
                    }
                </div>
            </div>
        )
    }

}

And here is the component which renders BlOrderChatBox (it is the topmost stateful component):

import React from 'react'
import { POSTJSON } from '../../../js/ajax';
import config from '../../../config/config';
import { newEmptyArrayAble, arrayToArrayAbleItemsOrder, arrayAbleItemsOrderToArray, mergeArrayAbles, newArrayAble, firstOfArrayAble, isArrayAble } from '../../../js/data_structures/arrayable';

export default class BlOrderChatApp extends React.Component {

    static NEW_CHAT_ID = 0

    static MAX_NUMBER_OF_MESSAGES_TO_LOAD_PER_AJAX = 30

    constructor(props) {
        super(props)

        this.currentUser = globals.USER
        this.lastHandleSendMessagePromise = Promise.resolve()
        this.newMessagesMap = {}
        this.typingUsersDebouncedMap = {}

        // Imagine this comes from a database.
        const chat = {
            // ...
        }

        const initialState = {
            chats: newArrayAble(this.newChat(chat)),
            currentChatId: null,
            shouldSelectUserForNewChat: false,
            newChatReceivingUsers: newEmptyArrayAble(),
            isChatListLoading: false,
            isCurrentChatLoading: false,
            popoverIsOpen: false,
            popoverHasOpened: false,
            err: void 0,
            focusSendMessageTextarea: false,
            newChatsIdsMap: {},
            currentChatAuthActs: {},
            BlOrderChatComponent: null,
        }
        this.state = initialState

        this.handleLoadPreviousChatMessages = this.handleLoadPreviousChatMessages.bind(this)
    }

    POST(jsonData, callback) {
        let requestJSONData
        if (typeof jsonData === "string") {
            requestJSONData = {
                action: jsonData
            }
        }
        else {
            requestJSONData = jsonData
        }
        return POSTJSON(config.ORDER_CHAT_ENDPOINT_URI, {
            ...requestJSONData,
            order_chat_type: this.props.orderChatType,
        }).then(response => response.json()).then(json => {
            this.POSTResponseData(json, callback)
        })
    }

    POSTResponseData(data, callback) {
        if (data.err) {
            this.setState({
                err: data.err
            })
        }
        else {
            callback && callback(data)
        }
    }

    newChat(chat) {
        const newChat = {
            id: (chat && chat.id) || BlOrderChatApp.NEW_CHAT_ID,
            ord_id: this.props.ordId,
            users: (chat && chat.users && (isArrayAble(chat.users) ? chat.users : arrayToArrayAbleItemsOrder(chat.users))) || newEmptyArrayAble(),
            messages: (chat && chat.messages && (isArrayAble(chat.messages) ? chat.messages : arrayToArrayAbleItemsOrder(chat.messages))) || newEmptyArrayAble(),
            first_message_id: (chat && chat.first_message_id) || null,
            typing_users_ids_map: (chat && chat.typing_users_ids_map) || {},
        }
        return newChat
    }

    isChatNew(chat) {
        return (
            chat
            &&
            (chat.id == BlOrderChatApp.NEW_CHAT_ID || this.state.newChatsIdsMap[chat.id])
        )
    }

    loadPreviousChatMessages(chatId, lowestMessageIdOrNull, makeChatIdCurrent) {
        this.POST({
            act: 'loadPreviousChatMessages',
            chat_id: chatId,
            lowest_message_id: lowestMessageIdOrNull,
            max_number_of_messages_to_load: BlOrderChatApp.MAX_NUMBER_OF_MESSAGES_TO_LOAD_PER_AJAX
        }, json => {
            this.setState(prevState => {
                const chat = prevState.chats.items[chatId]
                const messages = arrayToArrayAbleItemsOrder(json.messages)

                const newChat = {
                    ...chat,
                    messages: mergeArrayAbles(messages, chat.messages)
                }
                const chats = mergeArrayAbles(prevState.chats, newArrayAble(newChat))
                return {
                    ...(makeChatIdCurrent ?
                        {
                            currentChatId: chatId,
                            focusSendMessageTextarea: true,
                        }
                        :
                        {
                            currentChatId: prevState.currentChatId,
                        }
                    ),
                    chats,
                    isCurrentChatLoading: false,
                }
            })
        })
    }

    loadPreviousChatMessagesIfNotAllLoaded(chatId) {
        let lowestMessageIdOrNull
        const chat = this.state.chats.items[chatId]
        if (
            !this.isChatNew(chat)
            &&
            (lowestMessageIdOrNull = (chat.messages.order.length && firstOfArrayAble(chat.messages).id) || null)
            &&
            lowestMessageIdOrNull != chat.first_message_id
        ) {
            this.setState({
                isCurrentChatLoading: true
            }, () => {
                this.loadPreviousChatMessages(chat.id, lowestMessageIdOrNull)
            })
        }
    }

    handleLoadPreviousChatMessages(chatId) {
        this.loadPreviousChatMessagesIfNotAllLoaded(chatId)
    }

    // ...

    render() {
        const currentChat = this.state.chats.items[this.state.currentChatId] || null
        const err = this.state.err

        return (
            <div className="bl-order-chat-app">
                <BlOrderChatBox currentUser={this.currentUser}
                    chats={arrayAbleItemsOrderToArray(this.state.chats)}
                    currentChat={currentChat}
                    isCurrentChatLoading={this.state.isCurrentChatLoading}
                    onLoadPreviousChatMessages={this.handleLoadPreviousChatMessages}
                    err={err} />
            </div>
        )
    }

}

I tried to remove all the irrelevant code to simplify the reading. Also here is the file which contains the chatSelector function (normalizes the chat array-able object) and the *ArrayAble* functions (an array-able object to me is basically an object which maps objects through their ids in items and has an order property which keeps the sort):

import { isUndefined, unshiftArray, findIndex } from "../utils";

export function chatSelector(chat, currentUser) {
    const newChat = { ...chat }
    newChat.messages = arrayAbleItemsOrderToArray(chat.messages).sort((a, b) => {
        const sortByUnixTs = a.sent_unix_ts - b.sent_unix_ts
        if (sortByUnixTs == 0) {
            return a.id - b.id
        }
        return sortByUnixTs
    })
    newChat.users = arrayAbleItemsOrderToArray(chat.users).filter(user => user.id != currentUser.id)
    return newChat
}

/**
 * Given an array-able object, returns its array representation using an order property.
 * This function acts as a selector function.
 * 
 * The array-able object MUST have the following shape:
 * 
 *      {
 *          items: {},
 *          order: []
 *      }
 * 
 * Where "items" is the object containing the elements of the array mapped by values found in "order"
 * in order.
 * 
 * @see https://medium.com/javascript-in-plain-english/https-medium-com-javascript-in-plain-english-why-you-should-use-an-object-not-an-array-for-lists-bee4a1fbc8bd
 * @see https://medium.com/@antonytuft/maybe-you-would-do-something-like-this-a1ab7f436808
 * 
 * @param {Object} obj An object.
 * @param {Object} obj.items The items of the object mapped by keys.
 * @param {Array} obj.order The ordered keys.
 * @return {Array} The ordered array representation of the given object.
 */
export function arrayAbleItemsOrderToArray(obj) {
    const ret = []
    for (const key of obj.order) {
        if (!isUndefined(obj.items[key])) {
            ret[ret.length] = obj.items[key]
        }
    }
    return ret
}

export function arrayToArrayAbleItemsOrder(array, keyProp = "id") {
    const obj = newEmptyArrayAble()
    for (const elem of array) {
        const key = elem[keyProp]
        obj.items[key] = elem
        obj.order[obj.order.length] = key
    }
    return obj
}

export function newEmptyArrayAble() {
    return {
        items: {},
        order: []
    }
}

export function isEmptyArrayAble(arrayAbleObj) {
    return !arrayAbleObj.order.length
}

export function mergeArrayAbles(arrayAbleObj1, arrayAbleObj2, prependObj2 = false) {
    const obj = newEmptyArrayAble()
    for (const key of arrayAbleObj1.order) {
        if (isUndefined(arrayAbleObj1.items[key])) {
            continue
        }
        obj.items[key] = arrayAbleObj1.items[key]
        obj.order[obj.order.length] = key
    }
    for (const key of arrayAbleObj2.order) {
        if (isUndefined(arrayAbleObj2.items[key])) {
            continue
        }
        if (!(key in obj.items)) {
            if (!prependObj2) {
                // Default.
                obj.order[obj.order.length] = key
            }
            else {
                unshiftArray(obj.order, key)
            }
        }
        obj.items[key] = arrayAbleObj2.items[key]
    }
    return obj
}

export function newArrayAble(initialItem = void 0, keyProp = "id") {
    const arrayAble = newEmptyArrayAble()
    if (initialItem) {
        arrayAble.items[initialItem[keyProp]] = initialItem
        arrayAble.order[arrayAble.order.length] = initialItem[keyProp]
    }
    return arrayAble
}

export function lastOfArrayAble(obj) {
    return (
        (
            obj.order.length
            &&
            obj.items[obj.order[obj.order.length - 1]]
        )
        ||
        void 0
    )
}

Thank you for your help. If there's something missing which I should have included, please, let me know!

UPDATE : Thanks to Sultan H. it has improved, though the scroll still blocks as soon as I get the reply from the server. See it here: https://streamable.com/3nzu0 Any idea on how to improve this behaviour further?

Thanks!

Here is an attempt to resolve the performance issue, it's not preferrable to do tasks inside the Arrow Function that calculates the new state, in this case, at loadPreviousChatMessages you are calculating stuff in the callback, which may yeild to a load while setting the state on that context.

Preferrable Changes, replace this.setState inside your function with this code, all I've done here is clear the context by moving all the tasks out:

            const chat = this.state.chats.items[chatId];
            const messages = arrayToArrayAbleItemsOrder(json.messages);
            const newChat = {
              ...chat,
              messages: mergeArrayAbles(messages, chat.messages);
            }
            const chats = mergeArrayAbles(prevState.chats, newArrayAble(newChat));
            const newState = {
              ...(
                makeChatIdCurrent ?
                  {
                      currentChatId: chatId,
                      focusSendMessageTextarea: true,
                  }
                  :
                  {
                      currentChatId: this.state.currentChatId,
                  }
              ),
              chats,
              isCurrentChatLoading: false,
            };

            this.setState(() => newState);

If that doesn't entirely solve the issue, can you tell if there was at least an improvment?

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

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