【问题标题】:React Hooks (Rendering Arrays) - Parent component holding a reference of children that are mapped vs Parent component holding the state of childrenReact Hooks (Rendering Arrays) - 持有被映射的孩子的引用的父组件与持有孩子状态的父组件
【发布时间】:2020-06-16 12:27:30
【问题描述】:

过去几天我一直在学习 react 中的钩子,我尝试创建一个场景,我需要在屏幕上渲染一个大网格,并根据我想要采取的操作更新节点的背景颜色.有两个动作会改变节点的背景颜色,这两个动作必须同时存在。

  • 单击节点时,光标悬停在节点上。
  • Grid 组件中存在一种算法,该算法将改变某些 节点。

在我看来,有多种方法可以实现这一点,但我在使用钩子的方式上遇到了一些麻烦。我将首先引导您完成我的思考过程,了解如何从我学到的知识中实现这一点,然后向您展示我尝试过的实现。我试图保留代码的重要部分,以便可以清楚地理解。如果我遗漏了什么或完全误解了一个概念,请告诉我。

  1. 孩子们可以保持自己的状态并知道如何更新自己。父级可以保存对列表中每个子级的引用,并在需要时从子级的引用中调用必要的函数以更新子级。

    • 适用于要执行的第一个和第二个操作。此解决方案不会导致性能问题,因为子级管理自己的状态,并且如果父级通过引用更新子级状态,则唯一要重新渲染的子级将是被调用的那个。
    • 从我阅读的内容来看,此解决方案被视为一种反模式。

    const Grid = () => {
        // grid array contains references to the GridNode's

        function handleMouseDown() {
            setIsMouseDown(true);
        }

        function handleMouseUp() {
            setIsMouseDown(false);
        }

        function startAlgorithm() {
            // call grid[row][column].current.markAsVisited(); for some of the children in grid.
        }

        return (
            <table>
                <tbody>
                {
                    grid.map((row, rowIndex) => {
                            return (
                                <tr key={`R${rowIndex}`}>
                                    {
                                        row.map((node, columnIndex) => {
                                            return (
                                                <GridNode
                                                    key={`R${rowIndex}C${columnIndex}`}
                                                    row={rowIndex}
                                                    column={columnIndex}
                                                    ref={grid[rowIndex][nodeIndex]}
                                                    onMouseDown={handleMouseDown}
                                                    onMouseUp={handleMouseUp}
                                                />
                                            );
                                        })
                                    }
                                </tr>
                            );
                        }
                    )
                }
                </tbody>
            </table>
        );
    };

    const GridNode = forwardRef((props, ref) => {
        const [isVisited, setIsVisited] = useState(false);

        useImperativeHandle(ref, () => ({
            markAsVisited: () => {
                setIsVisited(!isVisited);
            }
        }));

        function handleMouseDown(){
                setIsVisited(!isVisited);
            }

        function handleMouseEnter () {
                if (props.isMouseDown.current) {
                    setIsVisited(!isVisited);
                }
            }

        return (
            <td id={`R${props.row}C${props.column}`}
                onMouseDown={handleMouseDown}
                onMouseEnter={handleMouseEnter}
                className={classnames("node", {
                    "node-visited": isVisited
                })}
            />
        );
    });


2. 子节点的状态可以作为来自父节点的props,任何更新操作都可以在父节点内部实现。 (子节点被正确更新,渲染仅在必要的子节点中被调用,但 DOM 似乎结结巴巴。如果你以一定的速度移动鼠标,什么都不会发生,并且每个访问的节点都会立即更新。)

  • 不适用于第一个操作。子项被正确更新,渲染只在必要的子项中被调用,但 DOM 似乎结结巴巴。如果您以特定速度移动鼠标,则不会发生任何事情,并且每个访问的节点都会立即更新。

    const Grid = () => {
        // grid contains objects that have boolean "isVisited" as a property.

        function handleMouseDown() {
            isMouseDown.current = true;
        }

        function handleMouseUp() {
            isMouseDown.current = false;
        }

        const handleMouseEnterForNodes = useCallback((row, column) => {
            if (isMouseDown.current) {
                setGrid((grid) => {
                    const copyGrid = [...grid];

                    copyGrid[row][column].isVisited = !copyGrid[row][column].isVisited;

                    return copyGrid;
                });
            }
        }, []);

        function startAlgorithm() {
            // do something with the grid, update some of the "isVisited" properties.

            setGrid(grid);
        }

        return (
            <table>
                <tbody>
                {
                    grid.map((row, rowIndex) => {
                            return (
                                <tr key={`R${rowIndex}`}>
                                    {
                                        row.map((node, columnIndex) => {
                                            const {isVisited} = node;

                                            return (
                                                <GridNode
                                                    key={`R${rowIndex}C${columnIndex}`}
                                                    row={rowIndex}
                                                    column={columnIndex}
                                                    isVisited={isVisited}
                                                    onMouseDown={handleMouseDown}
                                                    onMouseUp={handleMouseUp}
                                                    onMouseEnter={handleMouseEnterForNodes}
                                                />
                                            );
                                        })
                                    }
                                </tr>
                            );
                        }
                    )
                }
                </tbody>
            </table>
        );
    };

    const GridNode = ({row, column, isVisited, onMouseUp, onMouseDown, onMouseEnter}) => {
        return useMemo(() => {
            function handleMouseEnter() {
                onMouseEnter(props.row, props.column);
            }

            return (
                <td id={`R${row}C${column}`}
                    onMouseEnter={handleMouseEnter}
                    onMouseDown={onMouseDown}
                    onMouseUp={onMouseUp}
                    className={classnames("node", {
                        "node-visited": isVisited
                    })}
                />
            );
        }, [props.isVisited]);
    }


关于这个话题,我有两个问题想问。

  1. 在第一个实现中;当节点改变其状态时,父组件不会重新渲染。如果在这种情况下有益,只使用这种反模式是错误的吗?

  2. 第二个实现出现卡顿的原因可能是什么?我花了一段时间阅读文档并尝试了不同的东西,但找不到发生口吃的原因。

【问题讨论】:

    标签: javascript reactjs react-hooks


    【解决方案1】:

    正如您所说,使用 refs 来控制子数据是一种反模式,但这并不意味着您不能使用它。

    这意味着,如果有更好和更高性能的方法,最好使用它们,因为它们可以提高代码的可读性并改善调试。

    在您的情况下,使用 ref 绝对可以更轻松地更新状态并且还可以防止大量重新渲染是实现上述解决方案的好方法

    第二个实现出现卡顿的原因可能是什么?我花了一段时间阅读文档并尝试了不同的东西,但找不到发生口吃的原因。

    第二个解决方案中的许多问题都源于您定义了在每次重新渲染时重新创建的函数,因此导致整个网格被重新渲染,而不仅仅是单元格。在 Grid 组件中使用 useCallback 来记忆这些函数

    此外,您应该在 GridNode 中使用 React.memo 而不是 useMemo。

    另外需要注意的是你在更新的时候改变了状态,相反你应该以不可变的方式更新它

    工作代码:

    const Grid = () => {
      const [grid, setGrid] = useState(getInitialGrid(10, 10));
      const isMouseDown = useRef(false);
      const handleMouseDown = useCallback(() => {
        isMouseDown.current = true;
      }, []);
    
      const handleMouseUp = useCallback(() => {
        isMouseDown.current = false;
      }, []);
    
      const handleMouseEnterForNodes = useCallback((row, column) => {
        if (isMouseDown.current) {
          setGrid(grid => {
            return grid.map((r, i) =>
              r.map((c, ci) => {
                if (i === row && ci === column)
                  return {
                    isVisited: !c.isVisited
                  };
                return c;
              })
            );
          });
        }
      }, []);
    
      function startAlgorithm() {
        // do something with the grid, update some of the "isVisited" properties.
    
        setGrid(grid);
      }
    
      return (
        <table>
          <tbody>
            {grid.map((row, rowIndex) => {
              return (
                <tr key={`R${rowIndex}`}>
                  {row.map((node, columnIndex) => {
                    const { isVisited } = node;
                    if (isVisited === true) console.log(rowIndex, columnIndex);
                    return (
                      <GridNode
                        key={`R${rowIndex}C${columnIndex}`}
                        row={rowIndex}
                        column={columnIndex}
                        isVisited={isVisited}
                        onMouseDown={handleMouseDown}
                        onMouseUp={handleMouseUp}
                        onMouseEnter={handleMouseEnterForNodes}
                      />
                    );
                  })}
                </tr>
              );
            })}
          </tbody>
        </table>
      );
    };
    
    const GridNode = ({
      row,
      column,
      isVisited,
      onMouseUp,
      onMouseDown,
      onMouseEnter
    }) => {
      function handleMouseEnter() {
        onMouseEnter(row, column);
      }
      const nodeVisited = isVisited ? "node-visited" : "";
      return (
        <td
          id={`R${row}C${column}`}
          onMouseEnter={handleMouseEnter}
          onMouseDown={onMouseDown}
          onMouseUp={onMouseUp}
          className={`node ${nodeVisited}`}
        />
      );
    };
    

    附:虽然useCallback 和其他记忆将有助于带来一些性能优势,但它仍然无法克服对状态更新和重新渲染的性能影响。在这种情况下,最好在子级中定义状态并为父级公开 ref

    【讨论】:

    • 感谢您的回答。但是,我仍然有一些疑问。即使您使用useCallback 进行功能娱乐,如果您的沙盒示例有足够大的网格(例如 70x70 网格),它看起来仍然会卡顿。我在阅读文档后尝试这样做并得到相同的结果。我创建了您创建的沙箱的克隆,它是使用第一种方法 (link) 实现的。
    • 第二个问题,React.memo 和 useMemo 有什么区别?我知道一个是 HOC,因此可以与类组件一起使用,但我认为我没有看到任何其他区别。
    • 使用此解决方案,父级控制一切,包括渲染,但通过setState 这样做,会导致节点树在每次设置状态时重新渲染。 React 会计算出它需要重新渲染哪些节点,但是随着它的扩展,会有一个性能折衷。
    • @Pirhan,memo 为您提供了实现自定义 areEqual 函数的灵活性,该函数有助于编写更复杂的逻辑,而 useMemo 仅执行基于依赖数组的浅层比较。此外,useMemo 的主要目的是记忆计算。同样正如我所说,第二种方法是必然的,因为渲染逻辑在父级中,并且重新渲染时的状态更新和计算会很昂贵。在这种情况下,最好使用第一种方法来保持子状态并暴露参考
    【解决方案2】:

    如上所述,该解决方案是一种反模式,因为您在两个级别上混合了呈现和业务逻辑。您不需要显式使用React.forwardRef,事实上根据文档您不应该使用,即使在编写 HOC(高阶组件)时也是如此。您不需要直接访问该元素并对其执行某种操作 - 让 React 做它的事情。它非常好,而且效率很高。

    通常,当您在有 n 个节点时在子节点树上调用重新渲染方法时,您不希望从顶级节点(在这种情况下为父节点)重新渲染,因为它将导致整个节点树重新渲染为新的 DOM 元素,而不是更新现有元素。

    您当前的解决方案结合了父触发渲染和子触发渲染。 React 页面有一个很好的例子,其中包含井字游戏应用程序,说明如何在不导致父级重新渲染的情况下渲染子级。

    您应该使用的策略是父节点具有对象结构,在这种情况下为 n^2 个节点(例如,10x10 用于参数),是将渲染功能传递给子节点,并让子节点节点处理渲染。

    当您从父节点触发渲染时,您有几个选项(假设是功能组件),它们确实属于可观察更新的情况。您希望能够将更新从父节点推送到子节点,以修改子节点状态,并让子节点自行更新。

    这是一个渲染子节点的示例,而父节点正在将更改传达给子节点。您会看到,与您的示例所具有的嵌套级别渲染相比,即使是大型网格,性能也能很好地扩展。

    https://codepen.io/jmitchell38488/pen/pogbKEb

    这是通过使用 RxJS observable/subject、React.useState 和 React.useEffect 的组合来实现的。我们在父节点和子节点中都使用useState 来处理渲染和道具更新,并使用useEffect 来绑定observable。 useState 在渲染之间是持久的,这意味着你不需要每次在父节点中更新时都重建整个网格,但即使你这样做了,React 也足够智能,可以确定你更新了一个节点的 props,而不是替换它。

    const Grid = (props) => {
      // When we update the grid, we trigger the parent to re-render
      const [grid, setGrid] = React.useState([]);
      const subject = new Rx.Subject();
      if (grid.length < 1) {
        const newGrid = [];
        for (i = 0; i < props.h; i++) {
          for (k = 0; k < props.w; k++) {
            if (!Array.isArray(newGrid[i])) {
              newGrid[i] = [];
            }
    
            newGrid[i][k] = {
              visited: false,
              id: `${i}${k}`
            };
          }
        }
        setGrid(newGrid);
      }
    
      // Tell our node to update
      handleClick = (node, visited) => {
        subject.next({
          id: node.id,
          visited: visited
        })
      };
    
      randomSetAllVisited = () => {
        const newGrid = [...grid];
        newGrid.forEach(row => {
          row.forEach(node => {
            node.visited = Math.random() * 2 >= 1;
          })
        })
    
        // Tell parent to re-render
        setGrid(newGrid);
    
        // Because our nodes use `useState`, they are persistent, if the structure of
        // grid is the same and the data is mostly the same. This is based on the `key={...}` value
        // in row.map, so we need to tell our children nodes to re-render manually
        subject.next({
          reset: true
        })
      };
    
      randomSetAnyVisited = () => {
        const h = Math.floor(Math.random()*props.h);
        const w = Math.floor(Math.random()*props.w);
        const node = grid[h][w];
        subject.next({
          id: node.id,
          visited: true
        });
      };
    
      // Watch console.log to see how frequently parent renders
      console.log("rendering parent");
    
      return (
        <div>
          <table>
            <tbody>
              {grid.map((row, rowIndex) => (
                <tr key={`R${rowIndex}`}>
                  {row.map((node, columnIndex) => (<GridNode {...node} observer={subject.asObservable()} key={node.id} />))}
                </tr>
              ))}
            </tbody>
          </table>
          <button onClick={randomSetAllVisited}>Random set all visited</button>
          <button onClick={randomSetAnyVisited}>Random set any visited</button>
        </div>
      );
    };
    
    const GridNode = (props) => {
      // We need to set to undefined to handle full grid reset from parent
      const [visited, setVisited] = React.useState(undefined);
    
      // Toggle visited based on props and if visited is undefined
      if (props.visited !== visited && visited === undefined) {
        setVisited(props.visited);
      }
    
      // bind all this with useEffect, so we can subscribe/unsubscribe, and not block rendering, `useEffect` is a good practice
      React.useEffect(() => {
        // notifications that come from parent node, `setVisited` will re-render this node
        const obs = props.observer.subscribe(next => {
          if (!!next.id && next.id === props.id) {
            setVisited(next.visited !== undefined ? next.visited : !visited);
          } else if (!!next.reset) {
            setVisited(undefined);
          }
        });
        return () => obs.unsubscribe();
      }, [visited]);
    
      handleMouseEnter = () => {
        setVisited(!visited);
      }
    
      handleMouseLeave = () => {
        setVisited(!visited);
      }
    
      classes = ["node"];
      if (visited) {
        classes.push("node-visited");
      }
      return (<td onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} className={classes.join(" ")}/>);
    }
    

    在 codepen 示例中,我有一个 50x50 的网格,它没有卡顿、滞后或重新渲染子节点或更新子节点的问题。有两个帮助按钮可以随机化所有节点的状态,或者随机化单个节点。我已将其缩放到 100x100 以上,没有延迟或性能问题。

    【讨论】:

    • 首先,感谢您的回答,但我想澄清一些观点。你说如果我从顶层节点重新渲染,会导致整个节点树重新渲染。但是如果我使用React.memo,它会不会解决这个问题,因为它在这种情况下充当shouldComponetUpdate?我认为如果我在节点上使用React.memo,它将决定节点是否要重新渲染,因此父级重新渲染不会有问题。如果不是这种情况,那么使用它有什么意义?
    • 是防止重复渲染的性能优化功能。在这种情况下它可以与useState 和useEffect 一起使用吗 - 是的,它会提高性能,但不要依赖它来防止重新渲染。理想情况下,您希望避免渲染整个节点树,并且有几种策略可以做到这一点。
    • 所以我基本上想确保所有组件在没有memo 的情况下都能正常工作和性能,然后使用它来进一步提高性能。这也确保我不依赖它不重新渲染。我理解正确吗?另外,最后一个问题;有什么方法可以在不使用 RxJS 的情况下实现这一目标?我确实喜欢 RxJS 用于状态管理的想法和实现,但我也很好奇。
    • 使用 React.memo 在没有 rxjs 的情况下完成,从节点中删除 useState 并使它们成为纯函数。备忘录性能优化确实适用于计算成本高昂的组件,父级有,但子级没有。它并没有真正防止渲染。您应该使用 ClassComponent 来执行此操作。另外,使用 rxjs,它会让你的生活变得更轻松。
    • @Pirhan 如果您使用 codepen,您可以横向扩展至 100x100 网格,并且仍然不会对性能产生不利影响,即使在移动设备上也是如此。
    猜你喜欢
    • 1970-01-01
    • 2018-09-04
    • 1970-01-01
    • 2014-08-22
    • 1970-01-01
    • 2012-12-29
    • 2019-10-20
    • 2019-04-17
    • 1970-01-01
    相关资源
    最近更新 更多