从组件中的 useState 多次调用状态更新程序会导致多次重新渲染

Multiple calls to state updater from useState in component causes multiple re-renders

我第一次尝试 React hooks 一切都很好,直到我意识到当我获取数据并更新两个不同的状态变量(数据和加载标志)时,我的组件(一个数据 table ) 被渲染两次,即使对状态更新器的两次调用都发生在同一个函数中。这是我的 api 函数,它将两个变量返回到我的组件。

const getData = url => {

    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);

    useEffect(async () => {

        const test = await api.get('/people')

        if(test.ok){
            setLoading(false);
            setData(test.data.results);
        }

    }, []);

    return { data, loading };
};

在普通的 class 组件中,您只需调用一次即可更新状态,这可能是一个复杂的对象,但 "hooks way" 似乎是将状态拆分为更小的单元,一个当它们分别更新时,其副作用似乎是多次重新渲染。有什么想法可以缓解这种情况吗?

您可以将 loading 状态和 data 状态合并到一个状态对象中,然后您可以执行一个 setState 调用,并且只会有一个渲染。

注意:与class组件中的setState不同,useState返回的setState不合并对象在现有状态下,它完全替换了对象。如果你想进行合并,你需要自己读取以前的状态并将其与新值合并。参考docs.

在您确定存在性能问题之前,我不会太担心过度调用渲染。渲染(在 React 上下文中)和将虚拟 DOM 更新提交给真实的 DOM 是不同的事情。这里的渲染是指生成虚拟DOM,而不是更新浏览器DOM。 React 可能会批处理 setState 调用并使用最终的新状态更新浏览器 DOM。

const {useState, useEffect} = React;

function App() {
  const [userRequest, setUserRequest] = useState({
    loading: false,
    user: null,
  });

  useEffect(() => {
    // Note that this replaces the entire object and deletes user key!
    setUserRequest({ loading: true });
    fetch('https://randomuser.me/api/')
      .then(results => results.json())
      .then(data => {
        setUserRequest({
          loading: false,
          user: data.results[0],
        });
      });
  }, []);

  const { loading, user } = userRequest;

  return (
    <div>
      {loading && 'Loading...'}
      {user && user.name.first}
    </div>
  );
}

ReactDOM.render(<App />, document.querySelector('#app'));
<script src="https://unpkg.com/react@16.7.0-alpha.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@16.7.0-alpha.0/umd/react-dom.development.js"></script>
<div id="app"></div>

备选方案 - 编写您自己的状态合并钩子

const {useState, useEffect} = React;

function useMergeState(initialState) {
  const [state, setState] = useState(initialState);
  const setMergedState = newState => 
    setState(prevState => Object.assign({}, prevState, newState)
  );
  return [state, setMergedState];
}

function App() {
  const [userRequest, setUserRequest] = useMergeState({
    loading: false,
    user: null,
  });

  useEffect(() => {
    setUserRequest({ loading: true });
    fetch('https://randomuser.me/api/')
      .then(results => results.json())
      .then(data => {
        setUserRequest({
          loading: false,
          user: data.results[0],
        });
      });
  }, []);

  const { loading, user } = userRequest;

  return (
    <div>
      {loading && 'Loading...'}
      {user && user.name.first}
    </div>
  );
}

ReactDOM.render(<App />, document.querySelector('#app'));
<script src="https://unpkg.com/react@16.7.0-alpha.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@16.7.0-alpha.0/umd/react-dom.development.js"></script>
<div id="app"></div>

在 react-hooks 中批量更新 https://github.com/facebook/react/issues/14259

React currently will batch state updates if they're triggered from within a React-based event, like a button click or input change. It will not batch updates if they're triggered outside of a React event handler, like an async call.

补充一点回答

酷!对于那些计划使用这个钩子的人来说,可以用一种更健壮的方式来编写它来使用函数作为参数,比如这样:

const useMergedState = initial => {
  const [state, setState] = React.useState(initial);
  const setMergedState = newState =>
    typeof newState == "function"
      ? setState(prevState => ({ ...prevState, ...newState(prevState) }))
      : setState(prevState => ({ ...prevState, ...newState }));
  return [state, setMergedState];
};

更新: 优化版本,当传入的部分状态没有改变时,状态不会被修改。

const shallowPartialCompare = (obj, partialObj) =>
  Object.keys(partialObj).every(
    key =>
      obj.hasOwnProperty(key) &&
      obj[key] === partialObj[key]
  );

const useMergedState = initial => {
  const [state, setState] = React.useState(initial);
  const setMergedState = newIncomingState =>
    setState(prevState => {
      const newState =
        typeof newIncomingState == "function"
          ? newIncomingState(prevState)
          : newIncomingState;
      return shallowPartialCompare(prevState, newState)
        ? prevState
        : { ...prevState, ...newState };
    });
  return [state, setMergedState];
};

这还有另一个使用useReducer的解决方案!首先我们定义新的 setState.

const [state, setState] = useReducer(
  (state, newState) => ({...state, ...newState}),
  {loading: true, data: null, something: ''}
)

之后我们可以简单地像旧的 类 this.setState 一样使用它,只是没有 this!

setState({loading: false, data: test.data.results})

正如您在我们的新 setState 中注意到的那样(就像我们之前对 this.setState 所做的那样),我们不需要一起更新所有状态!例如,我可以像这样改变我们的状态之一(它不会改变其他状态!):

setState({loading: false})

太棒了,哈?!

所以让我们把所有的部分放在一起:

import {useReducer} from 'react'

const getData = url => {
  const [state, setState] = useReducer(
    (state, newState) => ({...state, ...newState}),
    {loading: true, data: null}
  )

  useEffect(async () => {
    const test = await api.get('/people')
    if(test.ok){
      setState({loading: false, data: test.data.results})
    }
  }, [])

  return state
}

Typescript 支持。 感谢 P. Galbraith 回复此解决方案, 使用打字稿的可以使用这个:

useReducer<Reducer<MyState, Partial<MyState>>>(...)

其中 MyState 是您的状态对象的类型。

例如在我们的例子中,它将是这样的:

interface MyState {
   loading: boolean;
   data: any;
   something: string;
}

const [state, setState] = useReducer<Reducer<MyState, Partial<MyState>>>(
  (state, newState) => ({...state, ...newState}),
  {loading: true, data: null, something: ''}
)

以前的状态支持。 在评论中 user2420374 要求一种方法来访问我们 setState 中的 prevState,所以这里有一种方法可以实现这个目标:

const [state, setState] = useReducer(
    (state, newState) => {
        newWithPrevState = isFunction(newState) ? newState(state) : newState
        return (
            {...state, ...newWithPrevState}
        )
     },
     initialState
)

// And then use it like this...
setState(prevState => {...})

isFunction 检查传递的参数是函数(这意味着您正在尝试访问 prevState)还是普通对象。你可以找到 this implementation of isFunction by Alex Grande here.


通知。对于那些想大量使用这个答案的人,我决定把它变成一个图书馆。你可以在这里找到它:

Github: https://github.com/thevahidal/react-use-setstate

NPM:https://www.npmjs.com/package/react-use-setstate

这样做:

const [state, setState] = useState({ username: '', password: ''});

// later
setState({
    ...state,
    username: 'John'
});

如果你使用的是第三方钩子,无法将状态合并到一个对象中或使用useReducer,那么解决方案是使用:

ReactDOM.unstable_batchedUpdates(() => { ... })

丹·阿布拉莫夫推荐here

看到这个example

要从 class 组件复制 this.setState 合并行为, useReducer:

的 React 文档 recommend to use the functional form of useState with object spread -
setState(prevState => {
  return {...prevState, loading, data};
});

这两个状态现在合并为一个,这将为您节省一个渲染周期。

一个状态对象还有另一个优点:loadingdatadependent 状态。当状态放在一起时,无效的状态更改会变得更加明显:

setState({ loading: true, data }); // ups... loading, but we already set data

您甚至可以通过 1.) 使状态更好 ensure consistent states - loadingsuccesserror 等 - explicit 在你的状态和 2.) 使用 useReducer 将状态逻辑封装在 reducer 中:

const useData = () => {
  const [state, dispatch] = useReducer(reducer, /*...*/);

  useEffect(() => {
    api.get('/people').then(test => {
      if (test.ok) dispatch(["success", test.data.results]);
    });
  }, []);
};

const reducer = (state, [status, payload]) => {
  if (status === "success") return { ...state, data: payload, status };
  // keep state consistent, e.g. reset data, if loading
  else if (status === "loading") return { ...state, data: undefined, status };
  return state;
};

const App = () => {
  const { data, status } = useData();
  return status === "loading" ? <div> Loading... </div> : (
    // success, display data 
  )
}

const useData = () => {
  const [state, dispatch] = useReducer(reducer, {
    data: undefined,
    status: "loading"
  });

  useEffect(() => {
    fetchData_fakeApi().then(test => {
      if (test.ok) dispatch(["success", test.data.results]);
    });
  }, []);

  return state;
};

const reducer = (state, [status, payload]) => {
  if (status === "success") return { ...state, data: payload, status };
  // e.g. make sure to reset data, when loading.
  else if (status === "loading") return { ...state, data: undefined, status };
  else return state;
};

const App = () => {
  const { data, status } = useData();
  const count = useRenderCount();
  const countStr = `Re-rendered ${count.current} times`;

  return status === "loading" ? (
    <div> Loading (3 sec)... {countStr} </div>
  ) : (
    <div>
      Finished. Data: {JSON.stringify(data)}, {countStr}
    </div>
  );
}

//
// helpers
//

const useRenderCount = () => {
  const renderCount = useRef(0);
  useEffect(() => {
    renderCount.current += 1;
  });
  return renderCount;
};

const fetchData_fakeApi = () =>
  new Promise(resolve =>
    setTimeout(() => resolve({ ok: true, data: { results: [1, 2, 3] } }), 3000)
  );

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.0/umd/react.production.min.js" integrity="sha256-32Gmw5rBDXyMjg/73FgpukoTZdMrxuYW7tj8adbN8z4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.0/umd/react-dom.production.min.js" integrity="sha256-bjQ42ac3EN0GqK40pC9gGi/YixvKyZ24qMP/9HiGW7w=" crossorigin="anonymous"></script>
<div id="root"></div>
<script>var { useReducer, useEffect, useState, useRef } = React</script>

PS:确保 prefix 使用 use 自定义 Hooks(useData 而不是 getData)。也传递给 useEffect 的回调不能是 async.

您还可以使用 useEffect 来检测状态变化,并相应地更新其他状态值

除了 Yangshun Tay's 之外,您最好记住 setMergedState 函数,这样它将 return 每次渲染时引用相同的引用而不是新函数。如果 TypeScript linter 强制您将 setMergedState 作为父组件中的 useCallbackuseEffect 的依赖项传递,这可能是至关重要的。

import {useCallback, useState} from "react";

export const useMergeState = <T>(initialState: T): [T, (newState: Partial<T>) => void] => {
    const [state, setState] = useState(initialState);
    const setMergedState = useCallback((newState: Partial<T>) =>
        setState(prevState => ({
            ...prevState,
            ...newState
        })), [setState]);
    return [state, setMergedState];
};