谈谈对 React 新旧生命周期的理解

前言

在写这篇文章的时候,React 已经出了 17.0.1 版本了,虽说还来讨论目前 React 新旧生命周期有点晚了,React 两个新生命周期虽然出了很久,但实际开发我却没有用过,因为 React 16 版本后我们直接 React Hook 起飞开发项目。

但对新旧生命周期的探索,还是有助于我们更好理解 React 团队一些思想和做法,于是今天就要回顾下这个问题和理解总结,虽然还是 React Hook 写法香,但是依然要深究学习类组件的东西,了解 React 团队的一些思想与做法。

本文只讨论 React17 版本前的。

React 16 版本后做了什么

首先是给三个生命周期函数加上了 UNSAFE:

  • UNSAFE_componentWillMount
  • UNSAFE_componentWillReceiveProps
  • UNSAFE_componentWillUpdate

这里并不是表示不安全的意思,它只是不建议继续使用,并表示使用这些生命周期的代码可能在未来的 React 版本(目前 React17 还没有完全废除)存在缺陷,如 React Fiber 异步渲染的出现。

同时新增了两个生命周期函数:

  • getDerivedStateFromProps
  • getSnapshotBeforeUpdate

UNSAFE_componentWillReceiveProps

UNSAFE_componentWillReceiveProps(nextProps)

先来说说这个函数,componentWillReceiveProps

该子组件方法并不是父组件 props 改变才触发,官方回答是:

如果父组件导致组件重新渲染,即使 props 没有更改,也会调用此方法。如果只想处理更改,请确保进行当前值与变更值的比较。

先来说说 React 为什么废除该函数,废除肯定有它不好的地方。

componentWillReceiveProps函数的一般使用场景是:

  • 如果组件自身的某个 state 跟父组件传入的 props 密切相关的话,那么可以在该方法中判断前后两个 props 是否相同,如果不同就根据 props 来更新组件自身的 state。
    类似的业务需求比如:一个可以横向滑动的列表,当前高亮的 Tab 显然隶属于列表自身的状态,但很多情况下,业务需求会要求从外部跳转至列表时,根据传入的某个值,直接定位到某个 Tab。

但该方法缺点是会破坏 state 数据的单一数据源,导致组件状态变得不可预测,另一方面也会增加组件的重绘次数。

而在新版本中,官方将更新 state 与触发回调重新分配到了 getDerivedStateFromPropscomponentDidUpdate 中,使得组件整体的更新逻辑更为清晰。

新生命周期方法static getDerivedStateFromProps(props, state)怎么用呢?

getDerivedStateFromProps 会在调用 render 方法之前调用,并且在初始挂载及后续更新时都会被调用。它应返回一个对象来更新 state,如果返回 null 则不更新任何内容。

从函数名字就可以看出大概意思:使用 props 来派生/更新 state。这就是重点了,但凡你想使用该函数,都必须出于该目的,使用它才是正确且符合规范的。

getDerivedStateFromProps不同的是,它在挂载和更新阶段都会执行(componentWillReceiveProps挂载阶段不会执行),因为更新 state 这种需求不仅在 props 更新时存在,在 props 初始化时也是存在的。

而且getDerivedStateFromProps在组件自身 state 更新也会执行而componentWillReceiveProps方法执行则取决于父组件的是否触发重新渲染,也可以看出getDerivedStateFromProps并不是 componentWillReceiveProps方法的替代品.

引起我们注意的是,这个生命周期方法是一个静态方法,静态方法不依赖组件实例而存在,故在该方法内部是无法访问 this 的。新版本生命周期方法能做的事情反而更少了,限制我们只能根据 props 来派生 state,官方是基于什么考量呢?

因为无法拿到组件实例的 this,这也导致我们无法在函数内部做 this.fetch()请求,或者不合理的 this.setState()操作导致可能的死循环或其他副作用。有没有发现,这都是不合理不规范的操作,但开发者们都有机会这样用。可如果加了个静态 static,间接强制我们都无法做了,也从而避免对生命周期的滥用。

React 官方也是通过该限制,尽量保持生命周期行为的可控可预测,根源上帮助了我们避免不合理的编程方式,即一个 API 要保持单一性,做一件事的理念。

如下例子:

// before
componentWillReceiveProps(nextProps) {
  if (nextProps.isLogin !== this.props.isLogin) {
    this.setState({
      isLogin: nextProps.isLogin,
    });
  }
  if (nextProps.isLogin) {
    this.handleClose();
  }
}

// after
static getDerivedStateFromProps(nextProps, prevState) {
  if (nextProps.isLogin !== prevState.isLogin) { // 被对比的props会被保存一份在state里
    return {
      isLogin: nextProps.isLogin, // getDerivedStateFromProps 的返回值会自动 setState
    };
  }
  return null;
}

componentDidUpdate(prevProps, prevState) {
  if (!prevState.isLogin && this.props.isLogin) {
    this.handleClose();
  }
}

UNSAVE_componentWillMount

UNSAFE_componentWillMount() 在挂载之前被调用。它在 render() 之前调用,因此在此方法中同步调用 setState() 不会触发额外渲染。

我们应该避免在此方法中引入任何副作用或事件订阅,而是选用componentDidMount()

在 React 初学者刚接触的时候,可能有这样一个疑问:一般都是数据请求放在componentDidMount里面,但放在componentWillMount不是会更快获取数据吗?

因为理解是componentWillMount在 render 之前执行,早一点执行就早拿到请求结果;但是其实不管你请求多快,都赶不上首次 render,页面首次渲染依旧处于没有获取异步数据的状态。

还有一个原因,componentWillMount是服务端渲染唯一会调用的生命周期函数,如果你在此方法中请求数据,那么服务端渲染的时候,在服务端和客户端都会分别请求两次相同的数据,这显然也我们想看到的结果。

特别是有了 React Fiber,更有机会被调用多次,故请求不应该放在componentWillMount中。

还有一个错误的使用是在componentWillMount中订阅事件,并在componentWillUnmount中取消掉相应的事件订阅。事实上只有调用componentDidMount后,React 才能保证稍后调用componentWillUnmount进行清理。而且服务端渲染时不会调用componentWillUnmount,可能导致内存泄露。

还有人会将事件监听器(或订阅)添加到 componentWillMount 中,但这可能导致服务器渲染(永远不会调用 componentWillUnmount)和异步渲染(在渲染完成之前可能被中断,导致不调用 componentWillUnmount)的内存泄漏。

对于该函数,一般情况,如果项目有使用,则是通常把现有 componentWillMount 中的代码迁移至 componentDidMount 即可。

UNSAFE_componentWillUpdate

当组件收到新的 props 或 state 时,会在渲染之前调用 UNSAFE_componentWillUpdate()。使用此作为在更新发生之前执行准备更新的机会。初始渲染不会调用此方法。

注意,不能在该方法中调用 this.setState();在 componentWillUpdate 返回之前,你也不应该执行任何其他操作(例如,dispatch Redux 的 action)触发对 React 组件的更新。

首先跟上面两个函数一样,该函数也发生在 render 之前,也存在一次更新被调用多次的可能,从这一点上看就依然不可取了。

其次,该方法常见的用法是在组件更新前,读取当前某个 DOM 元素的状态,并在 componentDidUpdate 中进行相应的处理。但 React 16 版本后有 suspense、异步渲染机制等等,render 过程可以被分割成多次完成,还可以被暂停甚至回溯,这导致 componentWillUpdatecomponentDidUpdate 执行前后可能会间隔很长时间,这导致 DOM 元素状态是不安全的,因为这时的值很有可能已经失效了。而且足够使用户进行交互操作更改当前组件的状态,这样可能会导致难以追踪的 BUG。

为了解决这个问题,于是就有了新的生命周期函数:

getSnapshotBeforeUpdate(prevProps, prevState)
getSnapshotBeforeUpdate 在最近一次渲染输出(提交到 DOM 节点)之前调用。它使得组件能在发生更改之前从 DOM 中捕获一些信息(例如,滚动位置)。此生命周期的任何返回值将作为第三个参数传入 componentDidUpdate(prevProps, prevState, snapshot)

componentWillUpdate 不同,getSnapshotBeforeUpdate 会在最终的 render 之前被调用,也就是说在 getSnapshotBeforeUpdate 中读取到的 DOM 元素状态是可以保证与 componentDidUpdate 中一致的。

虽然 getSnapshotBeforeUpdate 不是一个静态方法,但我们也应该尽量使用它去返回一个值。这个值会随后被传入到 componentDidUpdate 中,然后我们就可以在 componentDidUpdate 中去更新组件的状态,而不是在 getSnapshotBeforeUpdate 中直接更新组件状态。避免了 componentWillUpdatecomponentDidUpdate 配合使用时将组件临时的状态数据存在组件实例上浪费内存,getSnapshotBeforeUpdate 返回的数据在 componentDidUpdate 中用完即被销毁,效率更高。

来看官方的一个例子:

class ScrollingList extends React.Component {
  constructor(props) {
    super(props);
    this.listRef = React.createRef();
  }

  getSnapshotBeforeUpdate(prevProps, prevState) {
    // 我们是否在 list 中添加新的 items?
    // 捕获滚动位置以便我们稍后调整滚动位置。
    if (prevProps.list.length < this.props.list.length) {
      const list = this.listRef.current;
      return list.scrollHeight - list.scrollTop;
    }
    return null;
  }

  componentDidUpdate(prevProps, prevState, snapshot) {
    // 如果我们 snapshot 有值,说明我们刚刚添加了新的 items,
    // 调整滚动位置使得这些新 items 不会将旧的 items 推出视图。
    //(这里的 snapshot 是 getSnapshotBeforeUpdate 的返回值)
    if (snapshot !== null) {
      const list = this.listRef.current;
      list.scrollTop = list.scrollHeight - snapshot;
    }
  }

  render() {
    return (
      
{/* ...contents... */}
); } }

如果项目中有用到componentWillUpdate的话,升级方案就是将现有的 componentWillUpdate 中的回调函数迁移至 componentDidUpdate。如果触发某些回调函数时需要用到 DOM 元素的状态,则将对比或计算的过程迁移至 getSnapshotBeforeUpdate,然后在 componentDidUpdate 中统一触发回调或更新状态。

除了这些,React 16 版本的依然还有大改动,其中引人注目的就是 Fiber,之后我还会抽空写一篇关于 React Fiber 的文章,可以关注我的个人技术博文 Github 仓库,觉得不错的话欢迎 star,给我一点鼓励继续写作吧~

参考:

你可能感兴趣的:(react.js)