从深度嵌套的组件更新状态而不重新渲染父组件

Update state from deeply nested component without re-rendering parents

我有一个大致如下结构的表单页面:

<Layout>
  <Page>
    <Content>
      <Input />
      <Map />
    </Content>
  </Page>
  <Button />
</Layout>

Map 组件应该只渲染一次,因为有一个在渲染时触发的动画。这意味着内容、页面和布局根本不应重新呈现。

当输入为空时,布局中的按钮应该被禁用。 Input 的值不受 Content 控制,因为状态更改会导致重新渲染 Map。

我尝试了一些不同的方法(使用 refs、useImperativeHandle 等),但 none 的解决方案对我来说感觉非常干净。在不更改布局、页面或内容的状态的情况下,将输入状态连接到按钮状态的最佳方法是什么?请记住,这是一个相当小的项目,代码库使用 "modern" React 实践(例如钩子),并且没有全局状态管理,如 Redux、MobX 等。

我有一个确定的方法来解决它,但有点复杂。 使用 createContext 和 useContext 将数据从布局传输到输入。这样你就可以在不使用 Redux 的情况下使用全局状态。 (redux 也顺便使用 context 来分发它的数据)。使用上下文可以防止 属性 在 Layout 和 Imput 之间更改所有组件。

我有第二个更简单的选项,但我不确定它是否适用于这种情况。如果其 属性 未更改,您可以将 Map 包装到 React.memo 以防止渲染。尝试很快,可能会奏效。

更新

我在地图组件上尝试了 React.memo。我修改了 Gennady 的例子。它在没有上下文的情况下工作得很好。您只需将 value 和 setValue 传递给链下的所有组件。您可以像这样轻松地通过所有 属性:<Content {...props} /> 这是最简单的解决方案。

import React, { useState, useRef, memo } from "react";
import "./styles.css";

const Layout = props => {
  const counter = useRef(0);
  counter.current += 1;

  return (
    <div className="GenericComponent">
      <div className="Counter">Layout rendered {counter.current} times</div>
      <Page {...props} />
      <Button {...props} />
    </div>
  );
};

const Page = props => {
  const counter = useRef(0);
  counter.current += 1;

  return (
    <div className="GenericComponent">
      <div className="Counter">Page rendered {counter.current} times</div>
      <Content {...props} />
    </div>
  );
};

const Content = props => {
  const counter = useRef(0);
  counter.current += 1;

  return (
    <div className="GenericComponent">
      <div className="Counter">Content rendered {counter.current} times</div>
      <Input {...props} />
      <Map />
    </div>
  );
};

const Map = memo(() => {
  const counter = useRef(0);
  counter.current += 1;

  return (
    <div className="GenericComponent">
      <div className="Counter">Map rendered {counter.current} times</div>
    </div>
  );
});

const Input = ({ value, setValue }) => {
  const counter = useRef(0);
  counter.current += 1;

  const onChange = ({ target: { value } }) => {
    setValue(value);
  };

  return (
    <>
      Input rendedred {counter.current} times{" "}
      <input
        type="text"
        value={typeof value === "string" ? value : ""}
        onChange={onChange}
      />
    </>
  );
};

const Button = ({ value }) => {
  const counter = useRef(0);
  counter.current += 1;

  return (
    <button type="button" disabled={value === ""}>
      Button (rendered {counter.current} times)
    </button>
  );
};

export default function App() {
  const [value, setValue] = useState("");

  return (
    <div className="App">
      <h1>SO Q#60060672, method 2</h1>

      <p>
        Type something into input below to see how rendering counters{" "}
        <s>update</s> stay the same, except for input and button
      </p>
      <Layout value={value} setValue={setValue} />
    </div>
  );
}

https://codesandbox.io/s/weathered-wind-wif8b

Here is an example (click here to play with it) that avoids re-render of Map. However, it re-renders other components because I pass children around. But if map is the heaviest, that should do the trick. To avoid rendering of other components you need to get rid of children prop but that most probably means you will need redux. You can also try to use context but I never worked with it so idk how it would affect rendering in general

import React, { useState, useRef, memo } from "react";
import "./styles.css";

const GenericComponent = memo(
  ({ name = "GenericComponent", className, children }) => {
    const counter = useRef(0);
    counter.current += 1;

    return (
      <div className={"GenericComponent " + className}>
        <div className="Counter">
          {name} rendered {counter.current} times
        </div>
        {children}
      </div>
    );
  }
);

const Layout = memo(({ children }) => {
  return (
    <GenericComponent name="Layout" className="Layout">
      {children}
    </GenericComponent>
  );
});

const Page = memo(({ children }) => {
  return (
    <GenericComponent name="Page" className="Page">
      {children}
    </GenericComponent>
  );
});

const Content = memo(({ children }) => {
  return (
    <GenericComponent name="Content" className="Content">
      {children}
    </GenericComponent>
  );
});

const Map = memo(({ children }) => {
  return (
    <GenericComponent name="Map" className="Map">
      {children}
    </GenericComponent>
  );
});

const Input = ({ value, setValue }) => {
  const onChange = ({ target: { value } }) => {
    setValue(value);
  };
  return (
    <input
      type="text"
      value={typeof value === "string" ? value : ""}
      onChange={onChange}
    />
  );
};

const Button = ({ disabled = false }) => {
  return (
    <button type="button" disabled={disabled}>
      Button
    </button>
  );
};

export default function App() {
  const [value, setValue] = useState("");

  return (
    <div className="App">
      <h1>SO Q#60060672</h1>

      <Layout>
        <Page>
          <Content>
            <Input value={value} setValue={setValue} />
            <Map />
          </Content>
        </Page>
        <Button disabled={value === ""} />
      </Layout>
    </div>
  );
}

更新

Below is version with context that does not re-render components except input and button:

import React, { useState, useRef, memo, useContext } from "react";
import "./styles.css";

const ValueContext = React.createContext({
  value: "",
  setValue: () => {}
});

const Layout = memo(() => {
  const counter = useRef(0);
  counter.current += 1;

  return (
    <div className="GenericComponent">
      <div className="Counter">Layout rendered {counter.current} times</div>
      <Page />
      <Button />
    </div>
  );
});

const Page = memo(() => {
  const counter = useRef(0);
  counter.current += 1;

  return (
    <div className="GenericComponent">
      <div className="Counter">Page rendered {counter.current} times</div>
      <Content />
    </div>
  );
});

const Content = memo(() => {
  const counter = useRef(0);
  counter.current += 1;

  return (
    <div className="GenericComponent">
      <div className="Counter">Content rendered {counter.current} times</div>
      <Input />
      <Map />
    </div>
  );
});

const Map = memo(() => {
  const counter = useRef(0);
  counter.current += 1;

  return (
    <div className="GenericComponent">
      <div className="Counter">Map rendered {counter.current} times</div>
    </div>
  );
});

const Input = () => {
  const { value, setValue } = useContext(ValueContext);

  const onChange = ({ target: { value } }) => {
    setValue(value);
  };

  return (
    <input
      type="text"
      value={typeof value === "string" ? value : ""}
      onChange={onChange}
    />
  );
};

const Button = () => {
  const { value } = useContext(ValueContext);

  return (
    <button type="button" disabled={value === ""}>
      Button
    </button>
  );
};

export default function App() {
  const [value, setValue] = useState("");

  return (
    <div className="App">
      <h1>SO Q#60060672, method 2</h1>

      <p>
        Type something into input below to see how rendering counters{" "}
        <s>update</s> stay the same
      </p>

      <ValueContext.Provider value={{ value, setValue }}>
        <Layout />
      </ValueContext.Provider>
    </div>
  );
}

Solutions rely on using memo to avoid rendering when parent re-renders and minimizing amount of properties passed to components. Ref's are used only for render counters