【问题标题】:Avoid rerendering every component in list while updating only one in React避免在 React 中只更新一个组件时重新渲染列表中的每个组件
【发布时间】:2021-12-11 21:36:11
【问题描述】:

我有一个使用 Firebase v9 的简单聊天应用程序,这些组件从父级到子级按以下层次顺序排列:ChatSectionChatChatLineEditMessage

我有一个名为useChatService 的自定义钩子在状态下保存messages 的列表,该钩子在ChatSection 中调用,该钩子返回messages,我将它们从ChatSection 传递给Chat,然后我循环遍历 messages 并为每条消息创建一个 ChatLine 组件。

我可以单击每条消息前面的Edit 按钮,它显示EditMessage 组件以便我可以编辑文本,然后当我按“Enter”时,函数updateMessage 被执行并更新消息在数据库中,但随后每个 ChatLine 都会再次重新渲染,随着列表变大,这是一个问题。

编辑 2:我已经完成了使用 Firebase v9 制作工作示例的代码,因此您可以在每次(添加、编辑或删除)消息之后可视化我正在谈论的重新呈现.我正在使用 ReactDevTools Profiler 来跟踪重新渲染。

ChatSection.js:

import useChatService from "../hooks/useChatService";
import { useEffect } from "react";
import Chat from "./Chat";
import NoChat from "./NoChat";
import ChatInput from "./ChatInput";

const ChatSection = () => {
  let unsubscribe;
  const { getChatAndUnsub, messages } = useChatService();

  useEffect(() => {
    const getChat = async () => {
      unsubscribe = await getChatAndUnsub();
    };

    getChat();

    return () => {
      unsubscribe?.();
    };
  }, []);

  return (
    <div>
      {messages.length ? <Chat messages={messages} /> : <NoChat />}
      <p>ADD A MESSAGE</p>
      <ChatInput />
    </div>
  );
};

export default ChatSection;

Chat.js:

import { useState } from "react";
import ChatLine from "./ChatLine";
import useChatService from "../hooks/useChatService";

const Chat = ({ messages }) => {
  const [editValue, setEditValue] = useState("");
  const [editingId, setEditingId] = useState(null);

  const { updateMessage, deleteMessage } = useChatService();

  return (
    <div>
      <p>MESSAGES :</p>
      {messages.map((line) => (
        <ChatLine
          key={line.id}
          line={line}
          editValue={line.id === editingId ? editValue : ""}
          setEditValue={setEditValue}
          editingId={line.id === editingId ? editingId : null}
          setEditingId={setEditingId}
          updateMessage={updateMessage}
          deleteMessage={deleteMessage}
        />
      ))}
    </div>
  );
};

export default Chat;

ChatInput:

import { useState } from "react";
import useChatService from "../hooks/useChatService";

const ChatInput = () => {
  const [inputValue, setInputValue] = useState("");
  const { addMessage } = useChatService();

  return (
    <textarea
      onKeyPress={(e) => {
        if (e.key === "Enter") {
          e.preventDefault();
          addMessage(inputValue);
          setInputValue("");
        }
      }}
      placeholder="new message..."
      onChange={(e) => {
        setInputValue(e.target.value);
      }}
      value={inputValue}
      autoFocus
    />
  );
};

export default ChatInput;

ChatLine.js:

import EditMessage from "./EditMessage";
import { memo } from "react";

const ChatLine = ({
  line,
  editValue,
  setEditValue,
  editingId,
  setEditingId,
  updateMessage,
  deleteMessage,
}) => {
  return (
    <div>
      {editingId !== line.id ? (
        <>
          <span style={{ marginRight: "20px" }}>{line.id}: </span>
          <span style={{ marginRight: "20px" }}>[{line.displayName}]</span>
          <span style={{ marginRight: "20px" }}>{line.message}</span>
          <button
            onClick={() => {
              setEditingId(line.id);
              setEditValue(line.message);
            }}
          >
            EDIT
          </button>
          <button
            onClick={() => {
              deleteMessage(line.id);
            }}
          >
            DELETE
          </button>
        </>
      ) : (
        <EditMessage
          editValue={editValue}
          setEditValue={setEditValue}
          setEditingId={setEditingId}
          editingId={editingId}
          updateMessage={updateMessage}
        />
      )}
    </div>
  );
};

export default memo(ChatLine);

EditMessage.js:

import { memo } from "react";

const EditMessage = ({
  editValue,
  setEditValue,
  editingId,
  setEditingId,
  updateMessage,
}) => {
  return (
    <div>
      <textarea
        onKeyPress={(e) => {
          if (e.key === "Enter") {
            // prevent textarea default behaviour (line break on Enter)
            e.preventDefault();
            // updating message in DB
            updateMessage(editValue, setEditValue, editingId, setEditingId);
          }
        }}
        onChange={(e) => setEditValue(e.target.value)}
        value={editValue}
        autoFocus
      />
      <button
        onClick={() => {
          setEditingId(null);
          setEditValue(null);
        }}
      >
        CANCEL
      </button>
    </div>
  );
};

export default memo(EditMessage);

useChatService.js:

import { useCallback, useState } from "react";
import {
  collection,
  onSnapshot,
  orderBy,
  query,
  serverTimestamp,
  updateDoc,
  doc,
  addDoc,
  deleteDoc,
} from "firebase/firestore";
import { db } from "../firebase/firebase-config";

const useChatService = () => {
  const [messages, setMessages] = useState([]);

  /**
   * Get Messages
   *
   * @returns {Promise<Unsubscribe>}
   */
  const getChatAndUnsub = async () => {
    const q = query(collection(db, "messages"), orderBy("createdAt"));

    const unsubscribe = onSnapshot(q, (snapshot) => {
      const data = snapshot.docs.map((doc, index) => {
        const entry = doc.data();

        return {
          id: doc.id,
          message: entry.message,
          createdAt: entry.createdAt,
          updatedAt: entry.updatedAt,
          uid: entry.uid,
          displayName: entry.displayName,
          photoURL: entry.photoURL,
        };
      });

      setMessages(data);
    });

    return unsubscribe;
  };

  /**
   * Memoized using useCallback
   */
  const updateMessage = useCallback(
    async (editValue, setEditValue, editingId, setEditingId) => {
      const message = editValue;
      const id = editingId;

      // resetting state as soon as we press Enter
      setEditValue("");
      setEditingId(null);

      try {
        await updateDoc(doc(db, "messages", id), {
          message,
          updatedAt: serverTimestamp(),
        });
      } catch (err) {
        console.log(err);
      }
    },
    []
  );

  const addMessage = async (inputValue) => {
    if (!inputValue) {
      return;
    }
    const message = inputValue;

    const messageData = {
      // hardcoded photoURL, uid, and displayName for demo purposes
      photoURL:
        "https://lh3.googleusercontent.com/a/AATXAJwNw_ECd4OhqV0bwAb7l4UqtPYeSrRMpVB7ayxY=s96-c",
      uid: keyGen(),
      message,
      displayName: "John Doe",
      createdAt: serverTimestamp(),
      updatedAt: null,
    };

    try {
      await addDoc(collection(db, "messages"), messageData);
    } catch (e) {
      console.log(e);
    }
  };

  /**
   * Memoized using useCallback
   */
  const deleteMessage = useCallback(async (idToDelete) => {
    if (!idToDelete) {
      return;
    }
    try {
      await deleteDoc(doc(db, "messages", idToDelete));
    } catch (err) {
      console.log(err);
    }
  }, []);

  const keyGen = () => {
    const s = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    return Array(20)
      .join()
      .split(",")
      .map(function () {
        return s.charAt(Math.floor(Math.random() * s.length));
      })
      .join("");
  };

  return {
    messages,
    getChatAndUnsub,
    updateMessage,
    addMessage,
    deleteMessage,
  };
};

export default useChatService;

当使用updateMessage 方法更新消息时,我只需要重新渲染受影响的ChatLine(添加和删除相同),而不是列表中的每个ChatLine,同时保持messages 状态通过从ChatSectionChat,我知道ChatSectionChat 应该重新渲染,但不是列表中的每个ChatLine。 (也记住了ChatLine

编辑 1:我想问题出在 useChatService.js 中的 setMessages(data),但我认为 React 只会重新渲染编辑的行,因为我在循环通过 Chat 组件中的 messages 时已经提供了 key={line.id},但我不知道如何解决这个问题。

【问题讨论】:

    标签: javascript reactjs react-hooks


    【解决方案1】:

    前奏

    您最近的几个问题似乎都围绕着试图防止 React 组件重新呈现。这很好,但不要花太多时间过早地优化。 React 开箱即用运行良好。

    关于memo HOC 和优化性能,甚至是docs 状态:

    此方法仅作为性能优化存在。不要依赖 它可以“阻止”渲染,因为这可能会导致错误。

    这意味着 React 仍然可以在需要时重新渲染组件。我相信映射messages 数组就是其中一种情况。当messages 状态更新时,它是一个新数组,因此必须重新渲染。 React 的和解需要重新渲染数组和数组的每个元素,但可能不需要更深入。

    您可以通过向ChatLine 添加一个记忆子组件来测试这一点,并观察即使ChatLine 被包裹在memo HOC 中,它仍然会重新渲染,而记忆子组件不是。

    const Child = memo(({ id }) => {
      useEffect(() => {
        console.log('Child rendered', id); // <-- doesn't log when messages updates
      })
      return <>Child: {id}</>;
    });
    

    ...

    const ChatLine = (props) => {
      ...
    
      useEffect(() => {
        console.log("Chatline rendered", line.id); // <-- logs when messages updates
      });
    
      return (
        <div>
          ...
              <Child id={line.id} />
          ...
        </div>
      );
    };
    
    export default memo(ChatLine);
    

    这里的要点应该是您不应该过早地进行优化。仅当您发现实际性能问题并且具有适当的基准/审核性能时,才应查看 memoization 和虚拟化等工具。

    您也不应该“过度优化”。我为与我一起工作的客户开发的 React 应用程序我们很早就这样做了,因为我们认为我们可以节省自己的时间,但最终随着时间的推移(并且随着我们对 React 钩子的熟悉)我们已经删除了大部分或几乎所有我们的“优化”,因为它们最终并没有真​​正为我们节省太多并增加了更多的复杂性。我们最终发现我们的性能瓶颈更多地与我们的架构和组件组合有关,而不是与列表中呈现的组件数量有关。

    建议的解决方案

    因此,您在多个组件中使用了 useChatService 自定义挂钩,但正如所写的那样,每个挂钩都是自己的实例,并提供自己的 messages 状态副本和其他各种回调。这就是为什么您必须将messages 状态作为道具从ChatSection 传递给Chat。这里我建议将 messages 状态和回调移动到 React 上下文中,这样每个 useChatService 钩子“实例”都可以提供 same 上下文值。

    使用聊天服务

    可能会被重命名,因为现在不仅仅是一个钩子

    创建上下文:

    export const ChatServiceContext = createContext({
      messages: [],
      updateMessage: () => {},
      addMessage: () => {},
      deleteMessage: () => {}
    });
    

    创建上下文提供者:

    getChatAndUnsub 没有等待任何东西,所以没有理由声明它async。记住所有回调以添加、更新和删除消息。

    const ChatServiceProvider = ({ children }) => {
      const [messages, setMessages] = useState([]);
    
      const getChatAndUnsub = () => {
        const q = query(collection(db, "messages"), orderBy("createdAt"));
    
        const unsubscribe = onSnapshot(q, (snapshot) => {
          const data = snapshot.docs.map((doc, index) => {
            const entry = doc.data();
    
            return { .... };
          });
    
          setMessages(data);
        });
    
        return unsubscribe;
      };
    
      useEffect(() => {
        const unsubscribe = getChatAndUnsub();
    
        return () => {
          unsubscribe();
        };
      }, []);
    
      const updateMessage = useCallback(async (message, id) => {
        try {
          await updateDoc(doc(db, "messages", id), {
            message,
            updatedAt: serverTimestamp()
          });
        } catch (err) {
          console.log(err);
        }
      }, []);
    
      const addMessage = useCallback(async (message) => {
        if (!message) {
          return;
        }
    
        const messageData = { .... };
    
        try {
          await addDoc(collection(db, "messages"), messageData);
        } catch (e) {
          console.log(e);
        }
      }, []);
    
      const deleteMessage = useCallback(async (idToDelete) => {
        if (!idToDelete) {
          return;
        }
        try {
          await deleteDoc(doc(db, "messages", idToDelete));
        } catch (err) {
          console.log(err);
        }
      }, []);
    
      const keyGen = () => { .... };
    
      return (
        <ChatServiceContext.Provider
          value={{
            messages,
            updateMessage,
            addMessage,
            deleteMessage
          }}
        >
          {children}
        </ChatServiceContext.Provider>
      );
    };
    
    export default ChatServiceProvider;
    

    创建useChatService 钩子:

    export const useChatService = () => useContext(ChatServiceContext);
    

    为应用提供聊天服务

    index.js

    import ChatServiceProvider from "./hooks/useChatService";
    
    ReactDOM.render(
      <React.StrictMode>
        <ChatServiceProvider>
          <App />
        </ChatServiceProvider>
      </React.StrictMode>,
      document.getElementById("root")
    );
    

    聊天部分

    使用useChatService 挂钩来使用messages 状态。

    const ChatSection = () => {
      const { messages } = useChatService();
    
      return (
        <div>
          {messages.length ? <Chat /> : <NoChat />}
          <p>ADD A MESSAGE</p>
          <ChatInput />
        </div>
      );
    };
    
    export default ChatSection;
    

    聊天

    删除编辑状态和设置器(稍后会详细介绍)。使用useChatService 钩子来消费messages 状态。

    const Chat = () => {
      const { messages } = useChatService();
    
      return (
        <div>
          <p>MESSAGES :</p>
          {messages.map((line) => (
            <ChatLine key={line.id} line={line} />
          ))}
        </div>
      );
    };
    
    export default Chat;
    

    聊天热线

    将编辑状态移至此处。使用布尔切换代替editingId 状态来进行编辑模式。将编辑 id 封装在来自上下文的 updateMessage 回调中。在本地管理所有编辑状态,不要将状态值和设置器作为回调传递给另一个组件调用。请注意,EditMessage 组件 API 已更新。

    const ChatLine = ({ line }) => {
      const [editValue, setEditValue] = useState("");
      const [isEditing, setIsEditing] = useState(false);
    
      const { updateMessage, deleteMessage } = useChatService();
    
      return (
        <div>
          {!isEditing ? (
            <>
              <span style={{ marginRight: "20px" }}>{line.id}: </span>
              <span style={{ marginRight: "20px" }}>[{line.displayName}]</span>
              <span style={{ marginRight: "20px" }}>{line.message}</span>
              <button
                onClick={() => {
                  setIsEditing(true);
                  setEditValue(line.message);
                }}
              >
                EDIT
              </button>
              <button
                onClick={() => {
                  deleteMessage(line.id);
                }}
              >
                DELETE
              </button>
            </>
          ) : (
            <EditMessage
              value={editValue}
              onChange={setEditValue}
              onSave={() => {
                // updating message in DB
                updateMessage(editValue, line.id);
                setEditValue("");
                setIsEditing(false);
              }}
              onCancel={() => setIsEditing(false)}
            />
          )}
        </div>
      );
    };
    

    在这里您可以使用memo HOC。您可以进一步向 React 提示 也许 如果 line id 保持相等,则此组件不应该重新渲染,但请记住,这并不能完全阻止组件被重新渲染。这只是一个暗示,也许 React 可以放弃重新渲染。

    export default memo(ChatLine, (prev, next) => {
      return prev.line.id === next.line.id;
    });
    

    编辑消息

    只需将道具代理到textareabutton 各自的道具。也就是说,让ChatLine保持它需要的状态。

    const EditMessage = ({ value, onChange, onSave, onCancel }) => {
      return (
        <div>
          <textarea
            onKeyPress={(e) => {
              if (e.key === "Enter") {
                // prevent textarea default behaviour (line break on Enter)
                e.preventDefault();
                onSave();
              }
            }}
            onChange={(e) => onChange(e.target.value)}
            value={value}
            autoFocus
          />
          <button type="button" onClick={onCancel}>
            CANCEL
          </button>
        </div>
      );
    };
    
    export default EditMessage;
    

    聊天输入

    useChatService 钩子中使用addMessage。我认为这里没有太大变化,但为了完整起见还是包括在内。

    const ChatInput = () => {
      const [inputValue, setInputValue] = useState("");
      const { addMessage } = useChatService();
    
      return (
        <textarea
          onKeyPress={(e) => {
            if (e.key === "Enter") {
              e.preventDefault();
              addMessage(inputValue);
              setInputValue("");
            }
          }}
          placeholder="new message..."
          onChange={(e) => {
            setInputValue(e.target.value);
          }}
          value={inputValue}
          autoFocus
        />
      );
    };
    
    export default ChatInput;
    

    【讨论】:

    • 最后一个问题,如果可以的话。假设我在Chat 组件中使用了一个上下文,并且我也需要子组件ChatLine 中的上下文中的一些东西。我应该在Chat 中使用它并将我需要的东西作为道具从Chat 传递给ChatLine,还是应该在ChatLine 中再次使用它?就逻辑和性能而言,哪个更好。
    • @Dwix 因为ChatChatLine 的直接父级,所以我想说这是从上下文传递一个道具和从ChatLine 的上下文中使用它之间的一种洗礼。由于ChatLine 已经在使用聊天服务挂钩,尽管它可以更轻松地解耦组件。 React 上下文 API 主要用于解决“道具钻探”问题,因此您无需显式地将道具从祖先通过所有中间子代传递给后代。它本身并不是为了性能,但肯定会使代码更易于使用和维护。
    【解决方案2】:

    这就是我的想法,你在ChatSection 中传递了Messages,这意味着当Messages 更新时,ChatSection 将重新渲染,其所有子级也将重新渲染。

    所以我的想法是从ChatSection 中删除Messages,然后只将它添加到Chat

    您已经在聊天中使用useChatService,所以添加Messages 应该会更好。

    试试这个,如果它有效,我们也会回来。

    如果仍然不符合您的要求,我们还有其他方法可以解决它。

    但是您必须为我们创建一个工作示例,以便我们查看并进行一些小的更改。

    【讨论】:

    • 我需要将messagesChatSection传递到Chat,因为我稍后会进行检查,如果消息为空我什至不会渲染Chat,我'将渲染另一个组件。是的,当我们 setMessagesChatSectionChat 重新渲染时,这是正常的,但我不希望重新渲染所有 ChatLine 组件列表,因为我已经记住了 ChatLine 组件,并添加了key 道具,同时在 Chat 中循环它们。所以我想知道为什么这不起作用以及如何只重新渲染我们正在更新或添加的行..
    • 您必须了解,当setMessages 更改时,您会重新渲染ChatSection &amp; Chat,因此所有保存的密钥都将从内存中删除。它就是这样工作的。并且滚动将重置。如果你有一个可行的例子,will 可以尝试使用其他方法来解决这个问题。
    • 您好,我已经更新了代码并提供了一个与 Firebase 挂钩的完整工作示例。我编辑了帖子并添加了 CodeSandbox 和 Netlify 的链接。谢谢。
    • 据我所知,它工作正常。您应该创建一个分页,因为它可以处理增加的数据量。我应该为您创建一个作为答案吗?
    • 这与它是否工作或分页无关,我想做的只是重新渲染受影响的ChatLine,而不是每次添加、更新或删除列表中的所有行其中。
    【解决方案3】:

    将 ChatLine 包裹在 React.memo 中,它会停止多个重新渲染。

    注意:根据您的用例更新 areEqual 函数。

    import { useState } from "react";
    import ChatLine from "./ChatLine";
    import useChatService from "../hooks/useChatService";
    
    function areEqual(prevProps, nextProps) {
      /*
      return true if passing nextProps to render would return
      the same result as passing prevProps to render,
      otherwise return false
      */
      return prevProps.line === nextProps.line;
    }
    const ChatLineMemo = React.memo(ChatLine, areEqual);
    
    const Chat = ({ messages }) => {
      const [editValue, setEditValue] = useState("");
      const [editingId, setEditingId] = useState(null);
    
      const { updateMessage, deleteMessage } = useChatService();
    
    
      return (
        <div>
          <p>MESSAGES :</p>
          {messages.map((line) => (
            <ChatLineMemo
              key={line.id}
              line={line}
              editValue={line.id === editingId ? editValue : ""}
              setEditValue={setEditValue}
              editingId={line.id === editingId ? editingId : null}
              setEditingId={setEditingId}
              updateMessage={updateMessage}
              deleteMessage={deleteMessage}
            />
          ))}
        </div>
      );
    };
    
    export default Chat;
    

    【讨论】:

    • 是的,我已经把ChatLine包裹在备忘录里了,我无法成功实现areEqual(prevProps, nextProps),即使prevProps.line === nextProps.line也不行,你可以自己测试看看。
    • @Dwix 您还可以尝试一件事,将函数包装到 useCallback 然后传递给 ChatLineMemo。
    • 我做到了,但这并没有改变任何事情。我必须在areEqual 函数中使用适当的条件,但我不能正确地做到这一点。你认为你能帮我解决这个具体案例吗?
    • return prevProps.line === nextProps.line; 行是字符串或对象。如果反对我们 line.msg
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2020-12-13
    • 2020-05-04
    • 1970-01-01
    • 1970-01-01
    • 2021-07-11
    • 2022-06-29
    • 1970-01-01
    相关资源
    最近更新 更多