javascript - 避免在 React 中只更新一个组件时重新渲染列表中的每个组件

标签 javascript reactjs react-hooks

我有一个使用 Firebase v9 的简单聊天应用程序,这些组件按此层次顺序从父级到子级: ChatSectionChatChatLineEditMessage
我有一个名为 useChatService 的自定义钩子(Hook),它在状态下保存 messages 列表,钩子(Hook)在 ChatSection 中调用,钩子(Hook)返回 messages ,我将它们从 ChatSection 在 Prop 中传递给 Chat ,然后我循环遍历 messages 并创建一个 ChatLine 组件每一条消息。
我可以单击每条消息前面的 Edit 按钮,它显示 EditMessage 组件,以便我可以编辑文本,然后当我按“Enter”时,函数 updateMessage 被执行并更新数据库中的消息,但随后每个 ChatLine再次重新渲染,随着列表变大,这是一个问题。
编辑 2: 我已经完成了使用 Firebase v9 制作工作示例的代码,因此您可以在每次(添加、编辑或删除)消息之后可视化我正在谈论的重新渲染。我正在使用 ReactDevTools Profiler 来跟踪重新渲染。

  • 这是完整的更新代码:CodeSandbox
  • 还部署在:Netlify
  • 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 状态从 ChatSection 传递到 Chat ,我了解 ChatSectionChat应该重新渲染,但不是列表中的每个 ChatLine。 (也 ChatLine 被内存)
    编辑 1:我猜问题出在 setMessages(data) 中的 useChatService.js 上,但我认为 React 只会重新渲染已编辑的行,因为在 key={line.id} 组件中循环遍历 messages 时我已经提供了 Chat,但我不知道如何解决这个问题。

    最佳答案

    序幕
    似乎您最近的几个问题都围绕着试图阻止 React 组件重新呈现。这很好,但不要花太多时间过早地优化。 React 开箱即用运行良好。
    关于memo HOC 和优化性能,甚至是 docs直接声明:

    This method only exists as a performance optimization. Do not rely on it to “prevent” a render, as this can lead to bugs.


    这意味着如果需要,React 仍然可以重新渲染组件。我相信映射 messages数组就是其中一种情况。当messages state 更新它是一个新数组,因此必须重新渲染。 React 的和解需要重新渲染数组和数组的每个元素,但可能不需要更深入。
    您可以通过向 ChatLine 添加一个内存子组件来测试它。并观看,即使 ChatLine包裹在 memo 中HOC 它仍然被重新渲染,而内存的 child 没有。
    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 钩子(Hook)的熟悉)我们已经删除了大部分或几乎所有我们的“优化”,因为它们最终并没有真​​正为我们节省太多并增加了更多的复杂性。我们最终发现我们的性能瓶颈更多地与我们的架构和组件组合有关,而不是与列表中呈现的组件数量有关。
    建议的解决方案
    所以您使用的是 useChatService几个组件中的自定义钩子(Hook),但正如所写的,每个钩子(Hook)都是它自己的实例,并提供它自己的 messages 的副本state 和其他各种回调。这就是为什么你必须通过 messages声明为来自 ChatSection 的 Prop 至Chat .这里我建议移动messages状态和回调到 React 上下文中,所以每个 useChatService钩子(Hook)“实例”可以提供相同上下文值。
    使用聊天服务
    (可能会被重命名,因为现在不仅仅是一个钩子(Hook))
    创建上下文:
    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 的钩子(Hook)状态。
    const ChatSection = () => {
      const { messages } = useChatService();
    
      return (
        <div>
          {messages.length ? <Chat /> : <NoChat />}
          <p>ADD A MESSAGE</p>
          <ChatInput />
        </div>
      );
    };
    
    export default ChatSection;
    
    聊天
    删除编辑状态和 setter (稍后会详细介绍)。使用 useChatService使用 messages 的钩子(Hook)状态。
    const Chat = () => {
      const { messages } = useChatService();
    
      return (
        <div>
          <p>MESSAGES :</p>
          {messages.map((line) => (
            <ChatLine key={line.id} line={line} />
          ))}
        </div>
      );
    };
    
    export default Chat;
    
    聊天热线
    将编辑状态移至此处。而不是 editingId state 对编辑模式使用 bool 切换。将编辑id封装在updateMessage中从上下文回调。管理 全部 在本地编辑状态,不要将状态值和 setter 作为回调传递给另一个组件调用。请注意 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;
    });
    
    编辑消息
    只需将 Prop 代理到textarea的各自 Prop 和 button .换句话说,让 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;
    
    聊天输入
    消费addMessage来自 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;
    
    Edit avoid-rerendering-every-component-in-list-while-updating-only-one-in-react

    关于javascript - 避免在 React 中只更新一个组件时重新渲染列表中的每个组件,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/70319227/

    相关文章:

    javascript - 将数据从服务传递到 Controller

    javascript - Jquery:什么 HTML 元素启动 ajax

    javascript - 单击 td 内的超链接时更改 td 颜色

    reactjs - react : Inject props through HOC without declaring types for them

    reactjs - Sequelize JOIN 查询在获取请求时抛出以下错误

    javascript - ESLint : Component definition is missing displayName (react/display-name)

    javascript - Backbone.js 相当于 django humanize - int.word 和 int.comma - 使用 underscore.string

    javascript - 以编程方式在 React 中添加更多组件

    javascript - React JS - 在 onClick 事件后触发 PHP

    javascript - 使用钩子(Hook)在调整大小时测量 React DOM 节点