重学Hooks——useEffect

3,400 阅读10分钟

useEffect

Dan在Overreacted上发表的文章深入浅出,本文只针对个人之前不理解的点进行思考,采用了他的案例,参考了他的文章——useEffect的完整指南.

在我看来,Effect hook是React Hooks中最强大最核心的一个hook,是驱动整个程序的纽带 我也将采用Dan的案例进行思考


  • 根据文章问题,进行思考后,抛出了以下几个问题,将在下文对这几个问题做详细的思考
    1. Effect是如何进行渲染的?
    2. 如何用Effect模拟React的生命周期?useEffect(fn,[])componentDidMount一样吗?
    3. 如何正确的使用Effect请求数据?
    4. Effect的依赖到底用什么,可以用函数嘛,什么时候用函数作为依赖?
    5. Effect Hook怎么会导致死循环?
    6. Effect Hook怎么会拿到旧state和props,如果我真的想用旧的state和props,我应该怎么去获取?

Effect到底是如何渲染的?

渲染中state的渲染

  • 以下是最简单的点击次数加一的事件, 分析一下点击后数字的改变
function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

以下展示了count改变的来源,并非是通过事件监听或是事件绑定或是代理等对count本身做出的改变,而是重新创建了一个count, 新创建的count值是最后一次改变的state中的count.

点击次数count来源
00useState默认值
11上一个useState的返回值
22上一个useState的返回值

代码体现则是如下:

function Counter() {
  const count = 0; // Returned by useState()
  // ...
  <p>You clicked {count} times</p>
  // ...
}

// After a click, our function is called again
function Counter() {
  const count = 1; // Returned by useState()
  // ...
  <p>You clicked {count} times</p>
  // ...
}

// After another click, our function is called again
function Counter() {
  const count = 2; // Returned by useState()
  // ...
  <p>You clicked {count} times</p>
  // ...
}
  • 因此发现,其实count只是一个常量,React在使用setCount后,带着一个新count再次调用组件!

至于更深入的研究,还未研究过,准备参考Dan的另一篇文章将 React 作为 UI 运行时

渲染中事件处理函数的渲染

function Counter() {
  const [count, setCount] = useState(0);

  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
      <button onClick={handleAlertClick}>
        Show alert
      </button>
    </div>
  );
}
  • 我们按照以下步骤做
    1. 我先点击到按钮,使count到达1
    2. 点击show alert,再3秒内迅速点击按钮使count到3
    3. 观察alert的值, 值为1 or 3 ??

点我操作,看看到底是个啥

  • 根据上文,以下展示了这个调用的情况
是否点击alert此时点击次数count来源handleAlertClick
00useState默认值handleAlertClick中的count取0
11上一个useState的返回值handleAlertClick中的count取1
11上一个useState的返回值handleAlertClick中的count取1
22上一个useState的返回值handleAlertClick中的count取2
33上一个useState的返回值handleAlertClick中的count取3
alert弹出.........弹出表格对应的第三行的handleAlertClick
  • 我们发现,每次调用的count和handleAlertClick,都是重新创建的counthandleAlertClick,每次重新渲染组件,上一次的栈内存都将被释放。由于闭包,第一次的count并未被释放,而handleAlertClick被存放在了任务队列,记录的是没有被释放的count,哪怕点到了3,与之前的也没有任何关系,每次渲染都是独立的,因此值是1。

每次渲染的state和props在渲染中是不会被改变的,因此每次渲染都是独立的,每次渲染的state和props都是不同的state和props。这种独立关系,再修改引用类型时,希望我们setObject(newObject),这样可以保证上一个state不被污染

问题1:Effect清理与浏览器渲染屏幕的执行顺序是什么样的呢?

// First render, props are {id: 10}
function Example() {
  // ...
  useEffect(
    // Effect from first render
    () => {
      ChatAPI.subscribeToFriendStatus(10, handleStatusChange);
      // Cleanup for effect from first render
      return () => {
        ChatAPI.unsubscribeFromFriendStatus(10, handleStatusChange);
      };
    }
  );
  // ...
}

// Next render, props are {id: 20}
function Example() {
  // ...
  useEffect(
    // Effect from second render
    () => {
      ChatAPI.subscribeToFriendStatus(20, handleStatusChange);
      // Cleanup for effect from second render
      return () => {
        ChatAPI.unsubscribeFromFriendStatus(20, handleStatusChange);
      };
    }
  );
  // ...
}

按照常理的逻辑,是这个顺序吗???????

次数操作
1渲染props.id为10的UI
2执行Effect,订阅数据
3清除id为10的Effect
4渲染props.id为20的UI
5执行Effect,订阅数据
yes????
No

结果应该是如下的

次数操作
1渲染props.id为10的UI
2执行Effect,订阅数据
3渲染props.id为20的UI
4清除id为10的Effect
5执行Effect,订阅数据

因为Effect的执行一定是放在浏览器渲染屏幕之后的!因为每次渲染都是独立的,上一个Effect只能记住id为10的状态,因此,effect的清除并不会读取最新props。它只能读取到定义它的那次渲染中的props值。

问题2:每次渲染的Effect都是不同的Effect嘛?那么Effect中的state和外部state是什么关系?

每次渲染的Effect都是不同的Effect Effect中的state和props,都是特定的那次渲染的state和props

React执行Effect的时机是什么?
function Counter() {
  const [count, setCount] = useState(0);
    // console.log('effect外部被执行了')
    useEffect(() => {
        // console.log('effect内部被执行了')
        document.title = count
    })
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}
  • 执行顺序如下
    1. 执行const [count, setCount] = useState(0);
    2. React记住Effect
    3. 渲染dom
    4. 调用document.title = count
  • 因此我们要记住,Effect是在每次更改作用于DOM并让浏览器绘制屏幕后去调用它

Effect中的异步

针对同步的情况,已经了解的差不多了,那么如果Effect中有延迟呢?

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setTimeout(() => {
      console.log(`You clicked ${count} times`);
    }, 3000);
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

假如我点击三次,将会打印什么,结论如下,原因很简单,因为每一个Effect中保存的是当前的count

You clicked 0 times
You clicked 1 times
You clicked 2 times
You clicked 3 times

但是!上述工作机制与类并不相同,hooks写法中,每一个count是独立的,类写法中,将会输出一次you clicked 0 times和3次you clicked 3 times,原因是因为类写法中的count是同一个count

这原来就是困扰我好久好久的Effect中的闭包啊

问题来了,如果我就想打印3次you clicked 3 times怎么办

ref登场

  • 不同于class中的ref,hooks中的ref不仅可以保存dom元素,他可以作为任何值的容器
function Example() {
  const [count, setCount] = useState(0);
  const latestCount = useRef(count);

  useEffect(() => {
    // Set the mutable latest value
    latestCount.current = count;
    setTimeout(() => {
      // Read the mutable latest value
      console.log(`You clicked ${latestCount.current} times`);
    }, 3000);
  });
  • ok,这样就搞定啦,官网说了, useRef返回的对象,在整个生命周期内保持不变,因此不用担心每次创建的都是新的ref,这样我Effect函数中改变的ref内的容器值,都是同一个。

  • 文章上半部分已经可以解决我们开篇提到的1、2、5、6三个问题

Effect到底是怎么更新的?


告诉React你要做什么样的比对

  • 我想关于这个依赖项,React文档做了更详尽的阐述。这里只拎几个点出来

类似于setState的函数式更新

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}
  • 由于每次渲染都是独立的,我们知道,这里count永远都是1
  • 解决方案如下:
    • 1、设置count作为依赖项
      • 虽然解决了问题,但是非常不好,代码如下,原因是因为每次修改count,都将重新生成一个定时器,useEffect都会被重新执行,这显然不是我们想要的结果
    • 2、函数式更新
      • 比较理想的解决方案
// 设置count为依赖项
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, [count]);

  return <h1>{count}</h1>;
}
// 函数式更新
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}
  • 这种函数式更新,无需知道count的值,React已经知道,并将最新的count传递进去。成功将依赖项count移除

但是

我不仅想要知道最新的count,我还想要知道最新的props或是其他的state。。(函数式更新凉凉)

function Counter() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(1);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + step);
    }, 1000);
    return () => clearInterval(id);
  }, [step]);

  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => setStep(Number(e.target.value))} />
    </>
  );
}

炸了,不是我们想要的

useReducer

  • 首先我们要知道,什么时候用这个useReducer?这里引用了React文档的内容。

在某些场景下,useReducer 会比 useState 更适用,例如 state 逻辑较复杂且包含多个子值,或者下一个 state 依赖于之前的 state 等。并且,使用 useReducer 还能给那些会触发深更新的组件做性能优化,因为你可以向子组件传递 dispatch 而不是回调函数。

这里默认已经会Redux了。

  • 关键点:
      1. 逻辑复杂
      1. 状态依赖
      1. 嵌套深的组件性能优化 其中第二点就是我们说的那一点,因此上面的案例可以改写为
function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { count, step } = state;

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: 'tick' });
    }, 1000);
    return () => clearInterval(id);
  }, [dispatch]);

  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => {
        dispatch({
          type: 'step',
          step: Number(e.target.value)
        });
      }} />
    </>
  );
}

const initialState = {
  count: 0,
  step: 1,
};

function reducer(state, action) {
  const { count, step } = state;
  if (action.type === 'tick') {
    return { count: count + step, step };
  } else if (action.type === 'step') {
    return { count, step: action.step };
  } else {
    throw new Error();
  }
}
  • 关键点: 用dispatch替代依赖(dispatch会保证生命周期内保持不变)
  • 这种模式的好处:通过reducer让我们不必再关心stateprops的状态,成功达到了解耦的目的。

但是问题来了,如果我每次都想获取最新的props,还有戏嘛?

有的,把reducer扔组件里

每次dispatch,都会调用reducer,这时候reducer获取的就是最新的props了。

function Counter({ step }) {
  const [count, dispatch] = useReducer(reducer, 0);

  function reducer(state, action) {
    if (action.type === 'tick') {
      return state + step;
    } else {
      throw new Error();
    }
  }

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: 'tick' });
    }, 1000);
    return () => clearInterval(id);
  }, [dispatch]);

  return <h1>{count}</h1>;
}

难怪Dan说是Hooks的作弊模式

接下来,我们着重解决第三和第四个问题

Effect中的数据请求


直接上案例

这个模式曾经也是我在不是很懂Effect的情况下经常使用的模式,我曾经对eslint-plugin-react-hooks这个插件提供的警告存有很大的疑问,现在终于明白了。

乍一看?没问题!

function SearchResults() {
  // Imagine this function is long
  function getFetchUrl() {
    return 'https://hn.algolia.com/api/v1/search?query=react';
  }

  // Imagine this function is also long
  async function fetchData() {
    const result = await axios(getFetchUrl());
    setData(result.data);
  }

  useEffect(() => {
    fetchData();
  }, []);
}
  • 但是上述模式存在一个弊端,如果我们忘记写入依赖,那么我们的effects就不会同步props和state带来的变更。这当然不是我们想要的。

我们把他放进去,前提是某些函数仅在effect中调用。

function SearchResults() {
  // ...
  useEffect(() => {
    // We moved these functions inside!
    function getFetchUrl() {
      return 'https://hn.algolia.com/api/v1/search?query=react';
    }
    async function fetchData() {
      const result = await axios(getFetchUrl());
      setData(result.data);
    }

    fetchData();
  }, []); // ✅ Deps are OK
  // ...
}

还是没问题吗?是的,没问题了。

稍微修改一下,使请求url中需要我们的状态,对,就是这么请求。

function SearchResults() {
  const [query, setQuery] = useState('react');


  useEffect(() => {
    // Imagine this function is also long
    function getFetchUrl() {
      return 'https://hn.algolia.com/api/v1/search?query=' + query;
    }

    // Imagine this function is also long
    async function fetchData() {
      const result = await axios(getFetchUrl());
      setData(result.data);
    }
    fetchData();
  }, [query]);
}

但我曾经有一次,在封装自定义Hook的时候,我将request提供给自定义hook,但是我却没法将我的state或者是props放进自定义hook,因为他们属于不同的js文件,这可怎么办? 说白了,就是逻辑复用咋搞。

useCallback

这个Hook很简单,缓存一个函数,只在函数本身需要改变的时候调用副作用

怎么知道这个函数是否需要改变,通过第二个参数

function SearchResults() {
  const [query, setQuery] = useState('react');
  const getFetchUrl = useCallback(() => {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }, [query]);  // ✅ Callback deps are OK

  useEffect(() => {
    const url = getFetchUrl('react');
    // ... Fetch data and do something ...
  }, [getFetchUrl]); // ✅ Effect deps are OK
}

问题解决!

Dan对class模式和hooks模式的这种网络请求没法放入依赖的情况分别作了比较,当request作为请求向下传递的情况。这里不细说了,class模式本身不是数据流的一部分,因此他必须将不必要的query传下去才能再componentDidUpdate中发生响应,而Hooks就完美的解决了这个问题

在我看来,useCallback就是一个工具人,我是老板,你没法直接跟我说话,就跟我秘书说。我秘书会传达给我的。

OK!1、2、3、4、5、6问题全都解决了!

总结&致谢

前前后后通读了Dan的文章好多遍,并看了好几遍Effect的文档。初识Effect,似乎很简单,随着项目的锻炼,发现越来越难以管理自己的状态。花心思重新学了一下Effect。让我更清晰的明白了设计Hooks的初衷和目的,找到正确使用的姿势。

从阅读到完成,花了大概有半个月,主要参考资料来自Dan的Overreacted上的一篇文章useEffect完整指南。