18

React Hooks: 深入剖析 useMemo 和 useEffect

 3 years ago
source link: https://zhuanlan.zhihu.com/p/268802571
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

React Hooks: 深入剖析 useMemo 和 useEffect

一棵需要定期修剪的树

最近 React 团队发布消息,称他们即将基于 Hooks 重写官方文档,Function Component 即将代替 Class Component 成为官方主推的开发方式。大家可能都开始慢慢从 Class Component 转向 Hooks,但是理想和现实还是有些差距,我们团队从 2019 年开始尝试使用 Hooks,事实也证明 Hooks 非常适合我们的业务场景,从一定程度上来讲可是大大提升了开发体验也降低了代码的维护成本,但是一方面项目旧代码主要还是基于 Class Component,另一方面 Hooks 本身也有一定的门槛,很多同学在开发的时候还是习惯把 Class Component 作为首选,在解决一些 Hooks 的问题时也不够娴熟。

基于上面的背景,我们团队组织了两次 React Hooks 分享,本文拎了两个最复杂(并没有)的 hook —— useEffect 和 useMemo 做一个整理,相信很多初用 Hooks 的同学也对他们有过疑惑。

(语雀地址:React Hooks: 深入剖析 useMemo 和 useEffect · 语雀

useEffect

useEffect 一般用于处理状态更新导致的 side effects。虽然说不提倡面向生命周期函数编程,但是在没有熟练掌握 useEffect 的时候,类比 Class Component 的生命周期函数最能帮助我们快速上手了。useEffect 可以看成 componentDidMount / componentDidUpdate / componentWillUnmount 这 3 个生命周期函数的替代。

这里贴一个官网的例子,可以非常全面的展示 useEffect 的使用方式:

import React, { useState, useEffect } from 'react';

// 该组件定时从服务器获取好友的在线状态
function FriendStatus(props) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }
    // 在浏览器渲染结束后执行
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    
    // 在每次渲染产生的 effect 执行之前执行
    return function cleanup() {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
    
   // 只有 props.friend.id 更新了才会重新执行这个 hook
  }, [props.friend.id]);

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}

useLayoutEffect

useEffect 是官方推荐拿来代替 componentDidMount / componentDidUpdate / componentWillUnmount 这 3 个生命周期函数的,但其实他们并不是完全等价,useEffect 是在浏览器渲染结束之后才执行的,而这三个生命周期函数是在浏览器渲染之前同步执行的,React 还有一个官方的 hook 是完全等价于这三个生命周期函数的,叫 useLayoutEffect。

这两者的区别可以看一下这个例子( codePen):

const App = () => {
  const [count, setCount] = React.useState(0);

  React.useEffect(() => {
    // 耗时 300 毫秒的计算
    const start = +new Date();
    while (+new Date() - start <= 300) {
      continue;
    }
    if (count === 0) {
      setCount(Math.random());
    }
  }, [count]);

  const handleClick = React.useCallback(() => setCount(0), []);

  return <button onClick={handleClick}>{count}</button>;
};

效果如下:

v2-1d159823f8f587b5b1d73418071b9071_b.jpg

如果我把 useEffect 换成 useLayoutEffect,得到的效果是:

v2-4b89f7ea0f2c1f53ea669f452982069a_b.jpg

(上面的例子改编自一篇掘金文章

这个例子可以很明显看出 useEffect 和 useLayoutEffect 之间的区别,useEffect 是在浏览器重绘之后才异步执行的,所以点击按钮之后按钮上的数字会先变成 0,再变成一个随机数;而 useLayoutEffect 是在浏览器重绘之前同步执行的,所以两次 setCount 合并到 300 毫秒后的重绘里了

因为 useEffect 不会阻塞浏览器重绘,而且平时业务中我们遇到的绝大多数场景都是时机不敏感的,比如取数、修改 dom、事件触发/监听…… 所以首推用 useEffect 来处理 side effects,性能上的表现会更好一些

ComponentWillReceiveProps

ComponentWillReceiveProps 是在组件接收到新 props 时执行的,和 useEffect 的执行时机完全不一致,事实上它和 useMemo 才是执行时机一致的,但是为什么却推荐用 useEffect 而不是 useMemo 来替代它呢?

我们来看看一个典型的 Class Component 可能会在 willReceiveProps 里做什么事情:

componentWillReceiveProps(nextProps) {
  
  if (nextProps.queryKey !== this.props.queryKey) {
    // 触发外部状态变更
    nextProps.setIsLoading(true);
    // 取数
    this.reFetch(nextProps.queryKey);
  }
  
  if (nextProps.value !== this.props.value) {
    // state 更新
    this.setState({
      checkList: this.getCheckListByValue(nextProps.value);
    })
  }
  
  if (nextProps.instanceId !== this.props.instanceId) {
    // 事件 / dom
    event.emit('instanceId_changed', nextProps.instanceId);
  }
  
}

这些代码是不是很眼熟? ComponentWillReceiveProps 经常被拿来:

    1. 触发回调,造成外部状态变更
    2. 事件监听和触发、dom 的变更
    3. state 更新

很明显前 3 种情况是时机不敏感的,为什么我们习惯在 ComponentWillReceiveProps 中做这些事情呢?因为 ComponentWillReceiveProps 可以第一时间拿到 props 和 nextProps ,方便我们做对比,而现在 React 已经接管了这个对比的工作,我们完全可以使用 useEffect 来替代,不阻塞浏览器重渲染,用户会觉得页面更加流畅。像取数这种经常涉及到复杂计算的场景,更是如此。

对于第 4 种情况我们需要思考一下,在组件更新期间更新状态是否是一个恰当的行为?归根到底组件需要动态根据某个 prop 来生成某个数据,如果在 Class Component 中,直接在 render 方法中生成即可,完全不需要 setState;如果是在 Function Component 中,确实是一个适合使用 useMemo 的场景,但是注意我们不是想要“更新状态”,而是因为“依赖改变了所以对象更新了”。

// 当 props.params 更新时,重新生成 newParams
const checkList = React.useMemo(() => {
 
  // 复杂的计算之后得到新的 checkList
  const newCheckList = props.value.map(each => ...)
  
  return newCheckList
}, [props.value])

useMemo

useMemo 是拿来保持一个对象引用不变的。useMemo 和 useCallback 都是 React 提供来做性能优化的。比起 classes, Hooks 给了开发者更高的灵活度和自由,但是对开发者要求也更高了,因为 Hooks 使用不恰当很容易导致性能问题。

比如我有这样一段 JSX:

<LineChart 
  dataconfig={{ // 取数配置
    ...dataConfig,
    datasetId: getDatasetId(queryId)
  }} 
  fetchData={(newDataConfig) => { // fetcher
    realFetchData(newDataConfig);
  }} 
/>

LineChart 会在 dataConfig 发生变化时重新取数,如果 LineChart 是一个 Class Component,那他的代码一般会这么写:

// Class Component
class LineChart extends React.Component {
  
  componentWillReceiveProps(nextProps) {
    // 当 dataConfig 发生变化时重新取数
    if (nextProps.dataConfig !== this.props.dataConfig) {
      nextProps.fetchData(nextProps.dataConfig);
    }
  }
  
}

如果用 Hooks 来实现,那么代码就变成了这样:

// Function Component
function LineChart ({ dataConfig, fetchData }) {
  
  React.useEffect(() => {
    fetchData(dataConfig);
  }, [dataConfig, fetchData])
  
}

从上面的代码中很明显看出 Class Component 和 Function Component 在开发心智上的区别,在 Class Component 中我们需要自己管理依赖。

比如上面的例子我们会手动判断前后 dataConfig 是否发生了变化,如果发生了变化再重新取数;而在 Function Component 中我们把依赖交给 React 自动管理了,虽然减少了手动做 diff 的工作量,但也带来了副作用:因为 React 做的是浅比较( Object.is() ),所以当 fetchData 的引用变化了,也会导致重新取数

但这个重取数逻辑上其实是合理的, 因为对于 React 来说,任何一个依赖项改变了都应该重新处理 hooks 中的逻辑,如果一个依赖的函数改变了,有可能是确实是函数体已经改变了。这和 React 的 callback ref 的处理方法是一致的: 如果每次传一个变化的 callback,那么 React 认为你需要重新处理这个 ref,因此他会重新初始化 ref。

虽然 React 对于依赖的处理是合理的,但是也需要解决引用变化导致的性能问题,这时候有两种解法:

    1. 把 fetchData 从依赖数组中去掉。听起来好像很完美,但是实际上却是个大坑,任何时候都不要使用这种方法。
    2. 想办法让 fetchData 的引用不变化。官方提供了一个 hooks —— useCallback 来解决函数引用的问题。
const fetchData = React.useCallback((newDataConfig) => {
    realFetchData(newDataConfig);
  }, [realFetchData]);

return <LineChart 
  dataconfig={{ // 取数配置
    ...dataConfig,
    datasetId: getDatasetId(queryId)
  }} 
  fetchData={fetchData} 
/>

这时候还没有彻底解决问题,因为只要 props 更新,LineChart 还是每次都会重新取数,你应该已经发现了,dataConfig 也是一个每次都会引用变化的 prop。memo 是 Hooks 中最容易被忽略的了,即使大家有意不在 JSX 中做计算,也经常会出现这种情况:

const fetchData = React.useCallback((newDataConfig) => {
    realFetchData(newDataConfig);
  }, [realFetchData]);

const dataCOnfig = getDataConfig(queryid);

return <LineChart 
  dataconfig={dataConfig} 
  fetchData={fetchData} 
/>

函数式编程就这种习惯,大家已经习惯这种无状态的写法,但是组件就是有状态的,状态更新了就得重新处理相关逻辑、重新渲染。我们得告诉 React 什么时候应该重新处理这个状态了,useMemo 就是拿来做这个的:

const fetchData = React.useCallback((newDataConfig) => {
    realFetchData(newDataConfig);
  }, [realFetchData]);

const dataConfig = React.useMemo(() => ({
    ...dataConfig,
    datasetId: getDatasetId(queryId)
  }), [getDatasetId, queryId]);

return <LineChart 
  dataconfig={dataConfig} 
  fetchData={fetchData} 
/>

这样 dataConfig 只有在 getDatasetId 或者 queryId 变化时才会重新生成,LineChart 只会在必要的时候才会重新取数。

你可能会发现你已经很注意用 useMemo 和 useCallback 来进行性能优化了,但是效果却不如人意。

只用 useMemo 和 useCallback 来做性能优化可能是无法得到预期效果的,原因是如果 props 引用不会变化,子组件不会重新渲染,但它依然会重新执行,看下面这个例子( codePen):

function Counter({ count }) {
  console.log('Counter 重新执行了!', count);
  
  // ...进行了一堆很复杂的计算!

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

function App() {
  const [count, setCount] = React.useState(0);
  const [stateAutoChange, setStateAutoChange] = React.useState(0);

  React.useEffect(() => {
    setInterval(() => {
      setStateAutoChange(s => s + 1);
    }, 500);
  }, []);

  return (
    <div>
      <div>{stateAutoChange}</div>
      <div>
        {/* count 是不会变化的 */}
        <Counter count={count} />
      </div>
    </div>
  );
}

如果 Counter 计算量很大,那瓶颈就不是重渲染而是重执行的这个过程了。如果想要阻断 Counter 重新执行,React 提供了一个 API:memo,它相当于 PureComponent,是一个高阶组件,默认对 props 做一次浅比较,如果 props 没有变化,则子组件不会重新执行。

那么给 Counter 套上 memo:

const Counter = React.memo(({ count }) => {
  console.log('Counter 重新执行了!', count);

  // ...进行了一堆很复杂的计算!
  
  return <span>{count}</span>;
});

世界清净了:

你也可以在 App 中用 useMemo 包裹 Counter ,效果是一样的:

function App() {

  // ...

  const memoCounter = React.useMemo(() => <Counter count={count} />, [count]);

  return (
    <div>
      <div>{stateAutoChange}</div>
      <div>{memoCounter}</div>
    </div>
  );
}

什么时候应该用 memo 和 useMemo?我们可能只有在复杂应用中才会关注到性能问题(某些简单的应用可能永远都不会出现性能问题- -),我的看法是:

  1. 对于组件开发者来说:开销比较大的组件都要用 memo,开销小的随便。比如上面的例子中,如果 Counter 只是单纯展示 count,重执行重渲染很明显不会造成性能问题,那爱用不用,可能 memo 做浅比较和存储上一个 props 值的开销比 Counter 重执行渲染的开销还要大。但是如果 Counter 涉及到大量计算,就要用 memo,一定可以减少性能开销。
  2. 对于平台/容器层开发者来说:(这里补充一下背景,我们团队是做产品搭建的,提供搭建引擎,引擎可能会接入很多组件,包括其他团队开发的三方组件)因为你甚至可能不知道接进来的组件是谁开发的,也无法保证组件的质量,那么可以用 useMemo 统一包裹接进来的组件,降低组件带来的性能影响
  3. 用 useMemo 和 useCallback 来控制子组件 props 的引用,和 memo 一起使用效果是最佳的,原因上面的例子也呈现了,子组件会有重新执行的开销,没有配套 memo 的话还可能出现反效果,这几个 API 各司其职,性能优化是一个整体的过程,不是单独在某一个组件里面做一些操作就可以得到改善的。推荐看一下官方文档 关于性能的 FAQ
  4. 虽然我说如果组件开销很小那用不用 memo 无所谓,但如果是一个大团队 + 一个非常大型的应用,协同的同学可能非常多,比如我们团队,人数本来就众多,因为业务膨胀,新同学源源不断加进来,还有众多外包同学,这时候代码的质量就需要做一个拉齐,不然维护成本会越来越大,那么可以制定规范(比如组件开发者就必须用 memo,比如传一个回调 prop 就必须用 useCallback),通过 eslint 来约束代码质量,让代码的风格尽量统一。

总之性能问题不是一个单点问题,一旦一个应用出现性能问题一般都要整条链路一起优化,对于复杂应用日常开发中就需要随时关注性能问题了,具体策略也是根据具体情况来,只要记住几个 API 的功能就可以了:useMemo 避免频繁的昂贵计算,useCallback 让 shouldComponentUpdate 可以正常发挥作用,memo 就是 shouldComponentUpdate。

context 导致的频繁更新就另说了,redux 已经提供了解决方案: useSelector,因为不在本文的讨论范围内所以此处忽略。

useMemo 掉坑实录

上面说了性能优化的一些个人看法,但是理论和实践毕竟是两码事,实践的时候 useMemo 还是很容易误操作导致没效果甚至反效果的,这里分享一个我遇到过的经典例子,囊括两个最容易产生的 useMemo 误操作。这里把场景做了一个简化( codePen)。

比如我想通过一个卡片渲染一个计时器:

在我点击 count 按钮的时候,count 会更新,计时器也会更新。为了节省性能,我希望当这个 Counter 是非 active 状态的时候(实际场景是在可视窗口外),不要引发额外的重渲染,所以这时候我会传给它旧的 props 来阻断它的渲染:

ComponentCard 的代码也很简单,他是一个通用容器,首先渲染一个 header,再渲染传进去的 Element:

效果如下:

v2-61260c76b37073612db754eea71ceb0e_b.jpg

这个 demo 有一个问题:每当这个组件从非 active 状态切到 active 时,都会有一次重渲染,因为 ComponentCard 逻辑比较复杂,导致会卡顿。映射到实际的的业务场景中,就是在滚动页面的时候页面会很卡(因为滑到可视窗口内的所有组件都会重渲染)

v2-38c6ea5cd8abd8119198b9dc7494dcc0_b.jpg
切到 active 的时候明显卡顿了

这个 demo 其实有两个问题:

    1. 组件从非 active 转变为 active 的时候会重渲染。
    2. 每次 ComponentCard 重渲染时 ComponentCardHeader 都会重渲染。

第 2 个问题比较简单,先排查第 2 个问题。找到对应的代码:

这段代码又反应了两个问题:

    1. useMemo 返回一个函数,而它的目的是存储一段 JSX,这么写会让 useMemo 失去作用,因为函数每次都会重新执行生成一个全新的 JSX。
    2. 每次 getStyleByColor 都会重新执行返回新的 props,即使第 1 点做对了也无法达到预期效果。

解决方法也很简单:

    1. getStyleByColor 返回的对象用 useMemo 包裹。
    2. getComponentCardHeader 换成 memoComponentHeader,存储最终的 JSX 而不是一个函数。

第 2 个问题已经解决,再看第 1 个问题。因为是 active 变化导致重渲染,所以先找到依赖 active 的 hook:

这也是一个很常见的问题,我们往往记得做第一层的 memo,却忘了做第二层的 memo,也就是说 memo 的粒度是原子性的,如果两个引用对象要合并,那他们需要分开 memo。在这段代码里,每次 active 从 false 变为 true,会进入到这个逻辑分支,生成一个新的 newProps,即使实际上它并没有产生变化。

解决方法是把 newProps 单独拎出来 memo 一下。

总结一下:

  1. 弄清楚你的 useMemo 的目的是什么,要么就是存一个对象 prop,要么就是存一个 JSX,没搞清楚目的之前就先别用 useMemo 了,像这个例子中 useMemo 不仅没有效果还增加了 useMemo 本身的开销。
  2. useMemo 的粒度是原子性的,useMemo 中用到其他引用类型也要做 memo,否则在某些场景下 useMemo 可能会失效。比较复杂的业务场景建议配合 useWhatChanged 和 Profile 一起食用。

deep memo

毕竟本文是个上手指南,所以必须要提一下 deep memo。如果整个应用是基于 Function Components,那么 Hooks 用起来应该很爽,如果是从一个基于 Class Components 的应用逐渐迁到 Function Components(大多数是这种情况),Hooks 用起来就没有那么爽了。

用 Class Component 的时候是很少人会去关注 props 的引用问题的,因为 Class Component 受到的影响会小很多,所以一个 Class Component 的 JSX 经常是这样的:

<Component 
  props={this.buildProps(configs)}
  onRendered={() => {
    // ...
    this.handleComponentRendered();
  }}
  ref={(ref) => {
    this.setComponentRef(ref)
  }}
  {...otherProps}
  />

看到没有,很多 props 都是动态计算的,这时候下面接 Function Component 就很容易火葬场。这时候子组件的 useMemo、useEffect 都会失效。根据我的观察,这时候会出现两种解决方式:

  1. 用 deep memo 解决(deep memo 是啥待会介绍)
  2. 换成 Class Component

第 2 种解决方式就是这篇文章出现了 deep memo 这一小节的原因。之所以会换回 Class Component,往往是因为组件开发者没有理解 useMemo 的作用原理导致的,useMemo 对他来说是一个 magic API。

memo 直译过来是“备忘录”的意思,本质上它就是把一个对象的引用记录下来防止函数组件弄丢而已,实现原理也很简单,用 useRef 就可以实现了:

// 只是一个简单实现,不是实际实现
function useMemo(callback, deps) {
  const refResult = React.useRef(callback());
  const depsRef = React.useRef(deps);

  const isDepsChanged = deps.some((dep, index) => dep !== depsRef.current[index]);

  depsRef.current = deps;

  // 依赖变化才重新执行 callback
  if (isDepsChanged) {
    refResult.current = callback();
  }

  return refResult.current;
}

理解 memo 的作用原理之后,再来看在 Class Components 应用中接入一个 Function Component 如何来控制该组件的性能。比如前面说到的 LineChart 的例子:

function LineChart ({ dataConfig, fetchData }) {
  
  React.useEffect(() => {
    fetchData(dataConfig);
  }, [dataConfig, fetchData])
  
}

因为 LineChart 并不知道 dataConfig 的变化时机,想要减少取数的次数,只能对 dataConfig 做深比较了,其实就是把 useMemo 中的浅比较换成深比较,也就是 deep memo,简单实现 useDeepMemo:

function useDeepMemo(value) {
  const refValue = React.useRef(value);

  // 深比较,有变化时更新引用
  if (!_.isEqual(refValue.current, value)) {
    refValue.current = value;
  }

  return value;
}

然后在 LineChart 中使用 useDeepMemo 对 dataConfig 做一个缓存:

function LineChart ({ dataConfig, fetchData }) {
  
  const memoDataConfig = useDeepMemo(dataConfig);
  
  React.useEffect(() => {
    fetchData(memoDataConfig);
  }, [memoDataConfig, fetchData])
  
}

这是对对象的处理,对于函数的处理,如果你知道上层是一个 Class Component,那么可以肯定 LineChart 拿到的 callback 总是可以拿到上层父组件中最新的 props 和 state 引用(除非它用了 const {state} = this 这种写法),换句话说它没有闭包问题。所以 callback 只需要拿到第一次渲染的时候那一个就可以了。

所以只需要用 useRef 对 fetchData 做一个缓存就可以了:

function LineChart ({ dataConfig, fetchData }) {
  
  const memoDataConfig = useDeepMemo(dataConfig);
  
  const refFetchData = React.useRef(fetchData)
  
  React.useEffect(() => {
    refFetchData.current(memoDataConfig);
  }, [memoDataConfig])
  
}

本文介绍了我使用 Hooks 的一些经验,篇幅比较长,但其实就介绍了两个 API。想系统掌握 Hooks 还是推荐阅读官方的 FAQ 。虽然说介绍 API 好像没有什么意思,因为 API 是永远学不完的,前端每天都有新的东西出现。不过熟悉 API 也是我们学习 Hooks 思想的基础,只有在大量使用了 Hooks 之后,才能理解为什么 Hooks 会成为 React 官方首推的开发方式。

没有深入使用 classes 和 functions 这两种开发方式的话,其实很难切身体会 Hooks 能给所谓 “giant Component”、“wrapper hell” 带来的改变,或者它真的利大于弊吗?

就我个人而言,如果只是开发一个像上面计时器一样的简单应用,那我觉得 classes 和 functions 对我来说没什么区别,只是语法不一样而已。但到了大型应用中,一切就不一样了,大型应用是很难避免出现大型组件的,特别是维护时间久了,业务逻辑膨胀,极端场景下一个组件几千行都有可能出现,这时候 classes 和 functions 就各显神通了。

classes 通过继承来解决逻辑复用问题,functions 通过组合来解决逻辑复用问题,这俩的思路完全不一样。也没有一定谁好谁坏,具体还是要根据业务场景来选择。比如做搭建引擎的,引擎其实很像一个工具包的组合,按需渲染是一块、取数是一块、联动是一块,还有吸顶、撤销重做…… 做搭建引擎相当于提供给组件开放一套数据流工具包和一个组件加载器,所以他的能力是比较分散的,没有一个实体,而 class 本质是一个实体,用 class 来聚合这些逻辑不如 hooks 组合的思路更好。

但如果是一个超级大型表格的开发者,表格是很重 dom 的,底层表格的能力基本上都围绕着 dom 来,比如懒渲染、布局、滚动等等,能力又是很内聚的,可以看成表格这个实体提供的一系列基于这个实体的能力,而且它又不怎么依赖 React 的生命周期和 state 能力这些 features。这种情况下,我觉得 classes 比 hooks 就更胜一筹了。

为什么一边觉得学 API 不高级一边又必须学 API 呢,不就是为了掌握它的本质嘛~ 所以学完 API 也不能只止于 API,只有通过 API 掌握背后的核心思想,才算是学到了新知识。

以上就是基于我们团队内两次 Hooks 分享,我得到的一些启发。如果你也想参与到我们的技术分享中来,那么刚刚好,我们团队正在招人中,感兴趣者欢迎私聊,或者直接投简历至: [email protected]


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK