面试题:Hooks 与 React 生命周期的关系

React 生命周期很多人都了解,但通常我们所了解的都是 单个组件的生命周期,但针对 Hooks 组件、多个关联组件(父子组件和兄弟组件) 的生命周期又是怎么样的喃?你有思考和了解过吗,接下来我们将完整的了解 React 生命周期。

关于 组件,我们这里指的是 React.Component以及 React.PureComponent,但是否包括 Hooks 组件喃?

一、Hooks 组件

函数组件的本质是函数,没有 state 的概念的,因此不存在生命周期一说,仅仅是一个 render 函数而已。

但是引入 Hooks之后就变得不同了,它能让组件在不使用 class 的情况下拥有 state,所以就有了生命周期的概念,所谓的生命周期其实就是 useStateuseEffect()useLayoutEffect()

即:Hooks 组件(使用了Hooks的函数组件)有生命周期,而函数组件(未使用Hooks的函数组件)是没有生命周期的

下面,是具体的 class 与 Hooks 的生命周期对应关系

  • constructor:函数组件不需要构造函数,我们可以通过调用 useState来初始化 state。如果计算的代价比较昂贵,也可以传一个函数给 useState

    const [num, UpdateNum] = useState(0)
  • getDerivedStateFromProps:一般情况下,我们不需要使用它,我们可以在渲染过程中更新 state,以达到实现 getDerivedStateFromProps的目的。

    function ScrollView({row}) {
      let [isScrollingDown, setIsScrollingDown] = useState(false);
      let [prevRow, setPrevRow] = useState(null);
    
      if (row !== prevRow) {
        // Row 自上次渲染以来发生过改变。更新 isScrollingDown。
        setIsScrollingDown(prevRow !== null && row > prevRow);
        setPrevRow(row);
      }
    
      return `Scrolling down: ${isScrollingDown}`;
    }

    React 会立即退出第一次渲染并用更新后的 state 重新运行组件以避免耗费太多性能。

  • shouldComponentUpdate:可以用 React.memo包裹一个组件来对它的 props进行浅比较

    const Button = React.memo((props) => {
      // 具体的组件
    });

    注意:React.memo等效于 PureComponent,它只浅比较 props。这里也可以使用 useMemo优化每一个节点。

  • render:这是函数组件体本身。

  • componentDidMount, componentDidUpdate: useLayoutEffect与它们两的调用阶段是一样的。但是,我们推荐你一开始先用 useEffect,只有当它出问题的时候再尝试使用 useLayoutEffectuseEffect可以表达所有这些的组合。

    // componentDidMount
    useEffect(()=>{
      // 需要在 componentDidMount 执行的内容
    }, [])
    
    useEffect(() => { 
      // 在 componentDidMount,以及 count 更改时 componentDidUpdate 执行的内容
      document.title = `You clicked ${count} times`; 
      return () => {
        // 需要在 count 更改时 componentDidUpdate(先于 document.title = ... 执行,遵守先清理后更新)
        // 以及 componentWillUnmount 执行的内容       
      } // 当函数中 Cleanup 函数会按照在代码中定义的顺序先后执行,与函数本身的特性无关
    }, [count]); // 仅在 count 更改时更新

    请记得 React 会等待浏览器完成画面渲染之后才会延迟调用 useEffect,因此会使得额外操作很方便

  • componentWillUnmount:相当于 useEffect里面返回的 cleanup函数

    // componentDidMount/componentWillUnmount
    useEffect(()=>{
      // 需要在 componentDidMount 执行的内容
      return function cleanup() {
        // 需要在 componentWillUnmount 执行的内容      
      }
    }, [])
  • componentDidCatchand getDerivedStateFromError:目前还没有这些方法的 Hook 等价写法,但很快会加上。

为方便记忆,大致汇总成表格如下。

class 组件Hooks 组件
constructoruseState
getDerivedStateFromPropsuseState 里面 update 函数
shouldComponentUpdateuseMemo
render函数本身
componentDidMountuseEffect
componentDidUpdateuseEffect
componentWillUnmountuseEffect 里面返回的函数
componentDidCatch
getDerivedStateFromError

二、单个组件的生命周期

1. 生命周期

V16.3 之前

我们可以将生命周期分为三个阶段:

分开来讲:

  1. 挂载阶段

    • constructor:避免将 props 的值复制给 state
    • componentWillMount
    • render:react 最重要的步骤,创建虚拟 dom,进行 diff 算法,更新 dom 树都在此进行
    • componentDidMount
  2. 组件更新阶段

    • componentWillReceiveProps
    • shouldComponentUpdate
    • componentWillUpdate
    • render
    • componentDidUpdate
  3. 卸载阶段

这种生命周期会存在一个问题,那就是当更新复杂组件的最上层组件时,调用栈会很长,如果在进行复杂的操作时,就可能长时间阻塞主线程,带来不好的用户体验,Fiber就是为了解决该问题而生。

V16.3 之后

Fiber 本质上是一个虚拟的堆栈帧,新的调度器会按照优先级自由调度这些帧,从而将之前的同步渲染改成了异步渲染,在不影响体验的情况下去分段计算更新。

对于异步渲染,分为两阶段:

  • reconciliation

    • componentWillMount
    • componentWillReceiveProps
    • shouldConmponentUpdate
    • componentWillUpdate
  • commit

    • componentDidMount
    • componentDidUpdate

其中,reconciliation阶段是可以被打断的,所以 reconcilation阶段执行的函数就会出现多次调用的情况,显然,这是不合理的。

所以 V16.3 引入了新的 API 来解决这个问题:

  1. static getDerivedStateFromProps: 该函数在挂载阶段和组件更新阶段都会执行,即每次获取新的propsstate之后都会被执行在挂载阶段用来代替componentWillMount;在组件更新阶段配合 componentDidUpdate,可以覆盖 componentWillReceiveProps的所有用法。

    同时它是一个静态函数,所以函数体内不能访问 this,会根据 nextPropsprevState计算出预期的状态改变,返回结果会被送给 setState返回 null则说明不需要更新 state,并且这个返回是必须的

  2. getSnapshotBeforeUpdate: 该函数会在 render之后, DOM 更新前被调用,用于读取最新的 DOM 数据。

    返回一个值,作为 componentDidUpdate的第三个参数;配合 componentDidUpdate, 可以覆盖componentWillUpdate的所有用法。

注意:V16.3 中只用在组件挂载或组件 props更新过程才会调用,即如果是因为自身 setState 引发或者forceUpdate 引发,而不是由父组件引发的话,那么static getDerivedStateFromProps也不会被调用,在 V16.4 中更正为都调用。

即更新后的生命周期为:

  1. 挂载阶段

    • constructor
    • static getDerivedStateFromProps
    • render
    • componentDidMount
  2. 更新阶段

    • static getDerivedStateFromProps
    • shouldComponentUpdate
    • render
    • getSnapshotBeforeUpdate
    • componentDidUpdate
  3. 卸载阶段

2. 生命周期,误区

误解一:getDerivedStateFromProps和 componentWillReceiveProps只会在 props改变时才会调用

实际上,只要父级重新渲染,getDerivedStateFromProps和 componentWillReceiveProps都会重新调用,不管 props有没有变化。所以,在这两个方法内直接将 props 赋值到 state 是不安全的。

// 子组件
class PhoneInput extends Component {
  state = { phone: this.props.phone };

  handleChange = e => {
    this.setState({ phone: e.target.value });
  };

  render() {
    const { phone } = this.state;
    return <input onChange={this.handleChange} value={phone} />;
  }

  componentWillReceiveProps(nextProps) {
    // 不要这样做。
    // 这会覆盖掉之前所有的组件内 state 更新!
    this.setState({ phone: nextProps.phone });
  }
}

// 父组件
class App extends Component {
  constructor() {
    super();
    this.state = {
      count: 0
    };
  }

  componentDidMount() {
    // 使用了 setInterval,
    // 每秒钟都会更新一下 state.count
    // 这将导致 App 每秒钟重新渲染一次
    this.interval = setInterval(
      () =>
        this.setState(prevState => ({
          count: prevState.count + 1
        })),
      1000
    );
  }

  componentWillUnmount() {
    clearInterval(this.interval);
  }

  render() {
    return (
      <>
        <p>
          Start editing to see some magic happen :)
        </p>
        <PhoneInput phone='call me!' /> 
        <p>
          This component will re-render every second. Each time it renders, the
          text you type will be reset. This illustrates a derived state
          anti-pattern.
        </p>
      </>
    );
  }
}

实例可点击这里查看

当然,我们可以在 父组件App 中 shouldComponentUpdate比较 props 的 email 是不是修改再决定要不要重新渲染,但是如果子组件接受多个 props(较为复杂),就很难处理,而且 shouldComponentUpdate主要是用来性能提升的,不推荐开发者操作 shouldComponetUpdate(可以使用 React.PureComponet)。

我们也可以使用 在 props 变化后修改 state

class PhoneInput extends Component {
  state = {
    phone: this.props.phone
  };

  componentWillReceiveProps(nextProps) {
    // 只要 props.phone 改变,就改变 state
    if (nextProps.phone !== this.props.phone) {
      this.setState({
        phone: nextProps.phone
      });
    }
  }

  // ...
}

但这种也会导致一个问题,当 props 较为复杂时,props 与 state 的关系不好控制,可能导致问题

解决方案一:完全可控的组件

function PhoneInput(props) {
  return <input onChange={props.onChange} value={props.phone} />;
}

完全由 props 控制,不派生 state

解决方案二:有 key 的非可控组件

class PhoneInput extends Component {
  state = { phone: this.props.defaultPhone };

  handleChange = event => {
    this.setState({ phone: event.target.value });
  };

  render() {
    return <input onChange={this.handleChange} value={this.state.phone} />;
  }
}

<PhoneInput
  defaultPhone={this.props.user.phone}
  key={this.props.user.id}
/>

当 key变化时, React 会创建一个新的而不是更新一个既有的组件

误解二:将 props 的值直接复制给 state

应避免将 props 的值复制给 state

constructor(props) {
 super(props);
 // 千万不要这样做
 // 直接用 props,保证单一数据源
 this.state = { phone: props.phone };
}

三、多个组件的执行顺序

1. 父子组件

  • 挂载阶段

    两个阶段:

    • 阶段,由父组件开始执行到自身的 render,解析其下有哪些子组件需要渲染,并对其中 同步的子组件进行创建,按 递归顺序挨个执行各个子组件至 render,生成到父子组件对应的 Virtual DOM 树,并 commit 到 DOM。
    • 阶段,此时 DOM 节点已经生成完毕,组件挂载完成,开始后续流程。先依次触发同步子组件各自的 componentDidMount,最后触发父组件的。

注意:如果父组件中包含异步子组件,则会在父组件挂载完成后被创建。

所以执行顺序是:

父组件 getDerivedStateFromProps —> 同步子组件 getDerivedStateFromProps —> 同步子组件 componentDidMount —> 父组件 componentDidMount —> 异步子组件 getDerivedStateFromProps —> 异步子组件 componentDidMount

  • 更新阶段

    React 的设计遵循单向数据流模型,也就是说,数据均是由父组件流向子组件。

    • 阶段,由父组件开始,执行

      1. static getDerivedStateFromProps
      2. shouldComponentUpdate
更新到自身的 `render`,解析其下有哪些子组件需要渲染,并对 **子组件** 进行创建,按 **递归顺序** 挨个执行各个子组件至 `render`,生成到父子组件对应的 Virtual DOM 树,并与已有的 Virtual DOM 树 比较,计算出 **Virtual DOM 真正变化的部分** ,并只针对该部分进行的原生DOM操作。
  • 阶段,此时 DOM 节点已经生成完毕,组件挂载完成,开始后续流程。先依次触发同步子组件以下函数,最后触发父组件的。

    1. getSnapshotBeforeUpdate()
    2. componentDidUpdate()
React 会按照上面的顺序依次执行这些函数,每个函数都是各个子组件的先执行,然后才是父组件的执行。

所以执行顺序是:

父组件 getDerivedStateFromProps —> 父组件 shouldComponentUpdate —> 子组件 getDerivedStateFromProps —> 子组件 shouldComponentUpdate —> 子组件 getSnapshotBeforeUpdate —>  父组件 getSnapshotBeforeUpdate —> 子组件 componentDidUpdate —> 父组件 componentDidUpdate
  • 卸载阶段

    componentWillUnmount(),顺序为 父组件的先执行,子组件按照在 JSX 中定义的顺序依次执行各自的方法

    注意:如果卸载旧组件的同时伴随有新组件的创建,新组件会先被创建并执行完 render,然后卸载不需要的旧组件,最后新组件执行挂载完成的回调。

2. 兄弟组件

  • 挂载阶段

    若是同步路由,它们的创建顺序和其在共同父组件中定义的先后顺序是 一致的。

    若是异步路由,它们的创建顺序和 js 加载完成的顺序一致。

  • 更新阶段、卸载阶段

    兄弟节点之间的通信主要是经过父组件(Redux 和 Context 也是通过改变父组件传递下来的 props实现的),满足React 的设计遵循单向数据流模型因此任何两个组件之间的通信,本质上都可以归结为父子组件更新的情况

    所以,兄弟组件更新、卸载阶段,请参考 父子组件

走在最后:走心推荐一个在线编辑工具:StackBlitz,可以在线编辑 Angular、React、TypeScript、RxJS、Ionic、Svelte项目

预告:后续将加入高阶组件的生命周期,敬请期待小瓶子的下次更新。

原文链接:segmentfault.com

上一篇:eslint-config-medikoo-es3
下一篇:essentials

相关推荐

官方社区

扫码加入 JavaScript 社区