避免React生命周期的那些坑坑洼洼

800 阅读15分钟

“如何避免坑?”换种思维思考也就是“为什么会有坑?”在代码编写中,遇到的坑往往会有两种:

  • 在不恰当的时机调用了不合适的代码
  • 在需要调用的时候,没有调用

-- 来自:伯约文章

要避免生命周期的坑,就需要先了解React有那些生命周期?在React的不同版本生命周期的钩子函数也大同小异。React的生命周期分为三个挂载、更新、销毁阶段,不同的阶段触发不用的钩子函数。接下来我们就一一来看看。

React 15生命周期

生命周期测试例子,测试版本React 15.7.0

image.png

组件的初始化渲染(挂载)

2.png

constructor

constructor是类的构造函数,在组件初始化的时候只会执行一次,用于初始化state和绑定函数。

constructor(props) {
    console.log("进入constructor");
    super(props);
    this.state = { text: "这个子组件文本" };
 }

但是随着类属性的流行,我在很多的代码中看到不在写constructor,而是改用类属性。移除constructor的原因无非就是:

  • 让代码变得更加简洁
  • constructor并不是React生命周期的一部分
class LifeCycelContainer extends React.Component {
  state = {
    text: "组件文本",
    hideChild: false
  };
  render() {
    return (
      <div className="fatherContainer">
        {this.state.text}
      </div>
    );
  }
}
componentWillMount

该方法也是也是在挂载的时候调用一次,并且方法在render方法之前调用。该方法在React后期的版本就已经标记废弃。原因是在React异步机制下,该生命周期钩子可能会被多次调用。最直观的一个例子,在该方法中写了异步请求,那有可能会被多次触发。

render

render方法并不会去真正的操作DOM,它的作用是把需要的东西返回回来。真正渲染的工作,是挂载阶段的ReactDOM.render方法去操作。

componentDidMount

componentDidMount方法执行,意味着初始化挂载的操作基本完成。它主要用于组件加载完成时做某些操作,比如发起网络请求、绑定事件或者你已经可以对DOM进行操作了,该函数是接着 render 之后调用的。但 componentDidMount 一定是在真实 DOM 绘制完成之后调用吗?在浏览器端,我们可以这么认为。

但在其他场景下,尤其是 React Native 场景下,componentDidMount 并不意味着真实的界面已绘制完毕。由于机器的性能所限,视图可能还在绘制中。

组件更新阶段

1.png

componentWillReceiveProps

该方法在后续的版本已经标记弃用,被getDerivedStateFromProps方法替代。在早起的版本这个方法还是有用的,有用的原因是在很多人其实并没有很明白这个方法到底由什么触发:

  1. 当父组件修改传递给子组件的属性时,这个修改会带动子组件的对于属性的修改,触发componentWillReceiveProps生命周期。
  2. 当父组件触发了个子组件无关的属性也会触发子组件的componentWillReceiveProps,这说明componentWillReceiveProps方法的触发不一定都是由于父组件传递给子组件的属性改变而引入的。 image.png
shouldComponentUpdate

在更新的过程中,会触发render方法来生成新的虚拟DOM,进行diff找出需要修改的DOM。这个过程是很耗费时间的。在实际操作中,我们会无意触发render方法,为了避免不必要的render调用带来的性能消耗,所以React让我们可以在shouldComponent方法决定是否要执行余下的声明周期,默认它是返回true。我们也可以手动设置false,不进行余下的生命周期。

componentWillUpdate

在render函数之前执行,运行做一些不涉及真实DOM的操作。后续版本已经被废弃。

render

和挂载阶段一致

componentDidUpdate

在render函数之后执行,DOM已经更新完成。这个生命周期也经常被用来处理 DOM 操作。此外,我们也常常将 componentDidUpdate 的执行作为子组件更新完毕的标志通知到父组件。

组件销毁

3.png

componentWillUnmount

组件卸载之前触发的生命周期,该函数主要用于执行清理工作。一个比较常见的 Bug 就是忘记在 componentWillUnmount 中取消定时器,导致定时操作依然在组件销毁后不停地执行。所以一定要在该阶段解除事件绑定,取消定时器。在平时写代码的时候如果不解除事件绑定和定时器可能会带来意向不想的问题。

componentWillUnmount会在两种情况下触发

  • 组件在父组件中被移除(销毁)
  • 组件设置了KEY属性,父组件在re-render的时候发现key和上一次不一致了就会被移除

React 16生命周期

对于React16x版本的生命周期可以分为两个版本16.3和>=16.4。有一位大神弄了一个在线查看React生命周期的网页,有兴致的同学可看看,地址

生命周期测试例子,测试版本React 16.3.0

组件的初始化渲染(挂载)

2 (1).png

消失的 componentWillMount,新增的 getDerivedStateFromProps

注意这个的getDerivedSatateFromProps不是componentWillMount的替代品 。getRerivedSatateFromProps设计的初衷是为了替代componentWillReceiveProps。但是说用来替代componentWillReceiveProps也不是完全正确。具体的原因我会在后续说明。

  • getDerivedStateFromProps是一个静态方法,不依赖实例存储,所以在getDerivedStateFromProps方法内是访问不到this的。
static getDerivedStateFromProps(props, state) {
    this.xxx //  this -> null
}
  • getDerivedStateFromProps接受两个参数,第一个参数是接受来自父组件的props,第二参数是当前组件自生的state。
  • getDerivedStateFromProps需要一个对象格式的返回值,如果你没有返回值,React会发出警告。 image.png
  • getReriverSatateFromProps的返回值之所以不可或缺,是因为React需要使用这个返回值来更新组件的state。因此当你确实不存在“使用 props 派生state”这个需求的时候,最好是直接省略掉这个生命周期方法的编写,否则一定记得给它 return 一个 null。

image.png

  • 注意getDerivedStateFromProps对state的更新动作不是覆盖式,是针对性的更新。
其他的生命周期

16版本和15版本在挂载阶段的其他生命周期如出一辙的,这里就不过多的阐述了。

组件更新阶段

1 (1).png

getRerivedStateFromProps

React16.3和>=React16.4版本差异在哪?

React16.3和>=React16.4版本生命周期在加载和卸载都是一样的,差异就在更新阶段。在React16.4中,任何因素触发的组件的更新(包括this.setState和forceUpdate触发的更新流程)都会触发getRerivedStateFromProps,在React.16.3只有在父组件更新是会触发getRerivedStateFromProps。

这里请记住,在不同版本getRerivedStateFromProps方法的触发源点可能不同。

为什么要用getRerivedStateFromProps代替componentWillReceivedProps?

其实getRerivedStateProps并不能完全替代componentWillReceivedProps,而是保证了这个方法的单一性,相对来说是在做一个合理的减法。getRerivedSatetFromProps方法是一个静态方法,是拿不到组件实例的this,这就导致你无法咋在这个方法内做this.fetch、不合理的this.setState,这类副作用的操作。

因此,getDerivedStateFromProps 生命周期替代 componentWillReceiveProps 的背后,是 React 16 在强制推行『只用 getDerivedStateFromProps 来完成 props 到 state 的映射』这一最佳实践。意在确保生命周期函数的行为更加可控可预测,从根源上帮开发者避免不合理的编程方式,避免生命周期的滥用;

getSnapshotBeforeUpdate
// 组件更新时调用
  getSnapshotBeforeUpdate(prevProps, prevState) {
    console.log("getSnapshotBeforeUpdate方法执行");
    return "haha";
  }
  // 组件更新后调用
  componentDidUpdate(prevProps, prevState, valueFromSnapshot) {
    console.log("componentDidUpdate方法执行");
    console.log("从 getSnapshotBeforeUpdate 获取到的值是", valueFromSnapshot);
  }

image.png

  • getSnapshotBeforeUpdate是render方法之后,DOM更新之前执行。
  • getSnapshotBeforeUpdate的返回值会作为componentDidUpdate的第三个参数。
  • getSnapshotBeforeUpdate可以获取到跟新之前的DOM和更新之后的state、pprops。

组件销毁

16版本销毁阶段和15无差异。如果有不了解,请回看15版本声明周期的销毁阶段。

React15 和 React16的本质差别

在16版本开始React引入了Fiber架构。Fiber是React 16 对React核心算法的一次重写。Fiber的核心就是原本的同步渲染变成了异步渲染。

Fiber的初衷

Fiber的初衷就是解决React 15版本中JS无控制的长期占用主线程导致白屏、卡顿等情况。JavaScript在浏览器的主线程上运行,恰好与样式计算、布局以及许多情况下的绘制一起运行。如果JavaScript运行时间过长,就会阻塞这些其他工作,也可能导致掉帧。

Fiber核心目标

  • 把可中断的工作拆分成小任务
  • 对正在做的工作调整优先次序、重做、复用上次
  • 在父子任务之间从容切换(yield back and forth),以支持React执行过程中的布局刷新
  • 支持render()返回多个元素
  • 更好地支持error boundary

没有Fiber架构

在React16之前,每当组件更新时,都会生成一个新的虚拟DOM。然后和上一次的虚拟DOM进行diff,找出差异实现更新。这个过程是一个递归的过程。只要没有到最后一步,就会一直递归,最可怕的是,这是一个串行的过程,可想而知这有多么恐怖。

同步渲染的递归调用栈是非常深的,只有底层调用返回了,这个渲染过程才会逐层返回。但是同步的过程中,会导致主线程不能做其他事情,直到递归完成,还有就是如果这个递归渲染的时间过程,会造成页面的卡顿或者卡死。

有Fiber架构

在React16之后,引入了Fiber架构,Fiber将一个大的更新任务拆分成很多小的任务,每一个小的任务完成字后,渲染线程会把主线程交还回去,看看有没有优先级更高的工作需要处理,从而避免卡顿的情况。在整个过程中,线程不在是一去不回头的状态了,而是可以被打断的,这就是所谓的"异步渲染"。

换一个角度来看生命周期工作流

在上面说到React16之后的一个重大变革就是引入了Fiber架构,整个架构吧同步渲染变成了异步渲染,在异步渲染的过程中,这个异步是可以被"打断"的,但是注意"打断"是有原则的。

什么时候可以被打断?

如图,在React16中,生命周期被划分成两个阶段,render和commit。commit又可以细分为pre-commmit和commit。

  • render阶段:纯净没有副作用,可以暂停、重启、终止。
  • pre-commit阶段:可以读取DOM.
  • commit阶段:可以使用DOM,运行副作用,安排更新。

在render阶段是可以被打断的,而commit阶段是不可以被打断的,同步执行。

Why? render阶段可以被打断,commit阶段就不行了

因为render阶段的操作对用户是不可见的,无论怎么操作对用户来说都是零感知,但是commit阶段涉及到真实DOM的渲染,如果在用户眼皮下胡乱的更改视图,哪也太胆大包天了。

为什么要变更生命周期?

我们在回过头在想想为什么在React要"废旧立新"。在React16废弃了:

  • componentWillMount
  • componentWillUpdate
  • componentReceiveProps 这三个生命周期,这三个生命周期的共性就是出于render阶段,都可能被重复执行。重复执行的过程可能有很多风险:
  • componentWillxxx方法的异步请求可能被触发多次
  • componentWillxxx方法里面滥用setState导致重复渲染出现死循环。 所以,React16改造生命周期的主要动机就是配合Fiber架构带来的异步渲染。在改造的过程中,针对生命周期中长期被滥用的部分推行了具有强制性的最佳实践。

生命周期的那些坑坑洼洼

上面介绍了在不同版本的生命周期,那在生命周期中有那些坑了。开篇提到,出现坑就是在:

  • 在不恰当的时机调用了不合适的代码
  • 在需要调用的时候,没有调用 那避免这些坑就是
  • 不在恰当的时机调用不合适的代码
  • 在需要调用的时候,去正确调用。

函数组件的无效触发

函数组件是一种无状态的组件,无生命周期,它在任何情况下都会被触发。看个[例子]

import React from "react";
import ReactDom from "react-dom";

function TestComponent(props) {
  console.log("我重新渲染了");
  return <div>函数组件:1</div>;
}

class LifeCycle extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      text: "这个子组件文本",
      message: "这个是子组件消息"
    };
  }
  changeText = () => {
    this.setState({
      text: "修改后的子组件文本"
    });
  };
  changeMessage = () => {
    this.setState({
      text: "修改后的子组件消息"
    });
  };
  render() {
    return (
      <div className="container">
        <button onClick={this.changeText} className="changeText">
          修改子组件文本内容
        </button>
        <button onClick={this.changeMessage} className="changeText">
          修改子组件消息
        </button>
        <p>{this.state.text}</p>
        <p>{this.state.message}</p>
        <p className="textContent">
          <TestComponent />
        </p>
      </div>
    );
  }
}

class LifeCycelContainer extends React.Component {
  state = {
    text: "父组件文本",
    message: "父组件消息"
  };
  changeText = () => {
    this.setState({
      text: "修改后的父组件文本"
    });
  };
  changeMessage = () => {
    this.setState({
      message: "修改后的父组件消息"
    });
  };
  render() {
    return (
      <div className="fatherContainer">
        <button onClick={this.changeText} className="changeText">
          修改父组件文本
        </button>
        <button onClick={this.changeText} className="changeText">
          修改父组件消息
        </button>
        <p>{this.state.text}</p>
        <p>{this.state.message}</p>
        <LifeCycle />
      </div>
    );
  }
}

ReactDom.render(<LifeCycelContainer />, document.getElementById("root"));

image.png

函数组件任何情况下都会重新渲染。它并没有生命周期,但官方提供了一种方式优化手段,那就是 React.memo。

const MyComponent = React.memo(function MyComponent(props) {
  console.log("memo: 我重新渲染了");
  return <div>memo函数组件:2</div>;
});

image.png

React.memo 并不是阻断渲染,而是跳过渲染组件的操作并直接复用最近一次渲染的结果,这与 shouldComponentUpdate 是完全不同的。

React.Component的无效触发

定义shouldComponentUpdate函数来避免无效的触发

shouldComponentUpdate() {
    // xxxx
    return false;
}

React.PureComponent谨慎使用

class LifeComponent extends PureComponent{
    render(){
        return (
            <p>{this.props.title}</p>
        )
    }
}

React.PureComponent 与 React.Component 几乎完全相同,但 React.PureComponent 通过prop和state的浅对比来实现 shouldComponentUpate()。

如果React组件的 render() 函数在给定相同的props和state下渲染为相同的结果,在某些场景下你可以使用 React.PureComponent 来提升性能。

React.PureComponent 的 shouldComponentUpdate() 只会对对象进行浅对比。如果对象包含复杂的数据结构,它可能会因深层的数据不一致而产生错误的否定判断(表现为对象深层的数据已改变视图却没有更新, 原文:false-negatives)。当你期望只拥有简单的props和state时,才去继承 PureComponent ,或者在你知道深层的数据结构已经发生改变时使用 forceUpate() 。或者,考虑使用 不可变对象 来促进嵌套数据的快速比较。

componentWillMount

componentWillMount 在 React 中已被标记弃用,不推荐使用,主要原因是新的异步渲染架构会导致它被多次调用。所以网络请求及事件绑定代码应移至 componentDidMount 中。

componentWillMount在页面初始化render之前会执行一次或多次(async rendering)。

很多同学在此生命周期进行请求数据想加快首页的渲染速度,但是由于JavaScript中异步事件的性质,当您启动API调用时,浏览器会在此期间返回执行其他工作。当React渲染一个组件时,它不会等待componentWillMount它完成任何事情,React继续前进并继续render,没有办法“暂停”渲染以等待数据到达。componentDidMount操作更加合适做这些操作。

componentWillReceiveProps

componentWillReceiveProps被标记弃用,新版使用 getDerivedStateFromProps 取代,一方面是性能问题,另一方面是从根本上实现代码的最优解,避免副作用。

componentWillUnmount

记得在 componentWillUnmount 函数中去处理解除事件绑定,取消定时器等清理操作,以免引起不必要的bug。

添加边界处理

默认情况下,若一个组件在渲染期间(render)发生错误,会导致整个组件树全部被卸载。错误边界:是一个组件,该组件会捕获到渲染期间(render)子组件发生的错误,并有能力阻止错误继续传播。

错误边界是一种 React 组件,这种组件可以捕获并打印发生在其子组件树任何位置的 JavaScript 错误,并且,它会渲染出备用 UI,而不是渲染那些崩溃了的子组件树。错误边界在渲染期间、生命周期方法和整个组件树的构造函数中捕获错误。

注意,错误边界无法捕获以下场景中产生的错误:1、事件处理。2、异步代码(例如 setTimeout 或 requestAnimationFrame 回调函数)。3、服务端渲染。4、它自身抛出来的错误(并非它的子组件)。

如果一个 class 组件中定义了 static getDerivedStateFromError() 或 componentDidCatch() 这两个生命周期方法中的任意一个(或两个)时,那么它就变成一个错误边界。当抛出错误后,请使用 static getDerivedStateFromError() 渲染备用 UI ,使用 componentDidCatch() 打印错误信息。

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // 更新 state 使下一次渲染能够显示降级后的 UI
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // 你同样可以将错误日志上报给服务器
    logErrorToMyService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // 你可以自定义降级后的 UI 并渲染
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children; 
  }
}

代码来源

错误边界的工作方式类似于 JavaScript 的 catch {},不同的地方在于错误边界只针对 React 组件。只有 class 组件才可以成为错误边界组件。大多数情况下, 你只需要声明一次错误边界组件, 并在整个应用中使用它。

注意错误边界仅可以捕获其子组件的错误,它无法捕获其自身的错误。如果一个错误边界无法渲染错误信息,则错误会冒泡至最近的上层错误边界,这也类似于 JavaScript 中 catch {} 的工作机制。

边界处理的例子

总结

在日常的开发中可能你会遇到这样那样的坑,这里可能只是一些。也可能有些同学说我用的是React hooks,更根本没有这些生命周期,其实不管有没有用其实大致类似,只是模式不一样了。希望对大家有用,也希望更多的同学说出你遇到的坑,大家一起学习!skr~~~~