首页 > 解决方案 > React 无限滚动组件性能

问题描述

我在 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>
        )
    }

}

我在我正在编写的聊天应用程序中这样使用它:

...
<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} />
...

问题是,一旦我滚动到阈值并被onInfiniteLoad调用,在显示加载微调器之前和加载数据之后,滚动冻结并且组件变得无响应。我该如何解决这个问题?当我渲染微调器容器并在之前加载的消息之后,React 不应该只是附加新的 div 来保留之前添加的项目以保持组件的性能吗?如果没有,我缺少什么 React 的关键概念?

感谢您的关注!

更新:以下是附加组件:

BlOrderChat表示一个聊天窗口并呈现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, 包含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>
        )
    }

}

这是渲染的组件BlOrderChatBox(它是最顶层的有状态组件):

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

}

我试图删除所有不相关的代码以简化阅读。这里还有一个文件,它包含chatSelector函数(规范化聊天数组对象)和*ArrayAble*函数(对我来说,数组对象基本上是一个对象,它通过对象的 id 映射对象items并具有order保持排序的属性):

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

谢谢您的帮助。如果有什么我应该包括在内的遗漏,请告诉我!

更新:感谢Sultan H.它有所改进,尽管一旦我从服务器得到回复,滚动仍然会阻塞。在此处查看:https ://streamable.com/3nzu0 有关如何进一步改善此行为的任何想法?

谢谢!

标签: javascriptreactjs

解决方案


这是解决性能问题的尝试,最好不要在计算新状态的箭头函数内执行任务,在这种情况下,loadPreviousChatMessages您正在计算回调中的内容,这可能会在设置状态时产生负载那个上下文。

Preferrable Changes,this.setState用这段代码替换你的函数内部,我在这里所做的就是通过将所有任务移出来清除上下文:

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

如果这不能完全解决问题,你能判断是否至少有改进吗?


推荐阅读