react杂谈之componentDidMount

4,251 阅读5分钟

熟悉 react 的人对于 componentDidMount 肯定都不会陌生了, 这是一个非常常用的生命周期, 当组件挂载后这个函数就会被执行, 我们的一些数据请求通常都会放在这个生命周期中。那么问题来了, 当执行 componentDidMount 的时候浏览器的界面已经渲染完毕了吗?

从官网寻找答案

首先我们来翻一翻官方文档对于 componentDidMount 的解释

componentDidMount() 会在组件挂载后(插入 DOM 树中)立即调用。依赖于 DOM 节点的初始化应该放在这里。如需通过网络请求获取数据,此处是实例化请求的好地方。

这个方法是比较适合添加订阅的地方。如果添加了订阅,请不要忘记在 componentWillUnmount() 里取消订阅

你可以在 componentDidMount() 里直接调用 setState()。它将触发额外渲染,但此渲染会发生在浏览器更新屏幕之前。如此保证了即使在 render() 两次调用的情况下,用户也不会看到中间状态。请谨慎使用该模式,因为它会导致性能问题。通常,你应该在 constructor() 中初始化 state。如果你的渲染依赖于 DOM 节点的大小或位置,比如实现 modals 和 tooltips 等情况下,你可以使用此方式处理

这里面比较重要的两句话是这两句:

  1. componentDidMount() 会在组件挂载后(插入 DOM 树中)立即调用
  2. 此渲染会发生在浏览器更新屏幕之前

第一句话是什么意思呢?就是说当我们的组件被插入到 DOM 树之后,就会执行 componentDidMount 生命周期。那第二句话是什么意思呢?这句话是说如果我们在 componentDidMount 里面调用了 setState , 会触发一次额外渲染, 也就是说会再走一次 render 流程,但是这个过程会发生在用户界面更新之前。

仔细一想,这两句话好像有点冲突?你又和我说这个组件被插入到 DOM 树之后才会执行 componentDidMount, 又和我说我重新 setState 会发生在用户界面更新之前??组件被插入到DOM树不就代表着用户界面已经更新了吗?

nonono,其实并不然,我们来看看下面这段代码

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div id="demo">hello world</div>
  <button onclick="render()">render</button>
  <script>
    function sleep(time) {
      const now = Date.now();

      while(Date.now() - now <= time * 1000) {

      }
    }
    
    function render() {
      const div = document.querySelector('#demo');
      div.innerHTML = 'hello world 1';
      sleep(2);
      div.innerHTML = 'hello world 2';
    }
  </script>
</body>
</html>

一段很简单的代码,我通过 js 去修改 DOM 的内容,但是我修改了两次,中间会有一个等待 2s 的过程,那么用户此时是能够看见 hello word -> hello word 1 -> hello world 2 还是只能看见 hello world -> hello world 2?我们实际跑一下看看效果

nromal.gif

用户其实只能看见 hello world -> hello world 2 的转变,因为浏览器的js线程和渲染线程是互斥的,所以sleep的时候渲染线程是没办法去渲染用户界面的,即使我们的内容修改已经同步到了DOM中。

PS: 这里的 sleep 就是一个简单的同步等待函数

实战演练

所以 reactcomponentDidMount 和这个是一个道理,componentDidMount 中去重新 setState 的时候,是一个同步的过程,也就是说会立刻进行一次 rerender ,此时js线程仍然被占用。在这个时候我们上一次的内容其实已经到了DOM中,但是渲染线程无法渲染内容到用户界面上,所以用户看不见任何东西。

我们用一个简单的例子来理解一下

import React from 'react';

function requestData() {
  const now = Date.now();

  while(Date.now() - now <= 2 * 1000) {};

  return 'after componentDidMount data';
}
class App extends React.Component {
  state = {
    data: 'init data'
  }
  componentDidMount() {
    this.setState({
      data: requestData()
    })
  }
  render() {
    return (
      <div>
        <div>App</div>
        <div>{this.state.data}</div>
      </div>
    )
  }
}

export default App;

这是一段非常简单的 react 代码,我们在componentDidMount中去请求数据,这个会耗费两秒的时间,那当这段代码跑起来的时候是会先展示init data再展示after componentDidMount data吗?我们直接试一试。

react.gif

可以看到,它只会展示一个空白界面,并不会展示init data,这也是因为js阻塞了渲染所导致的。那么我们知道,这个时候虽然界面上没有显示出来,它其实应该已经挂载到了DOM中去,所以我们应该能够拿到init data才对,我们再来试一试

import React from 'react';

function requestData() {
  const now = Date.now();

  while(Date.now() - now <= 2 * 1000) {};

  return 'after componentDidMount data';
}
class App extends React.Component {
  state = {
    data: 'init data'
  }
  componentDidMount() {
    console.log('test data: ', document.querySelector('#test').innerHTML);
    this.setState({
      data: requestData()
    })
  }
  render() {
    return (
      <div>
        <div>App</div>
        <div id="test">{this.state.data}</div>
      </div>
    )
  }
}

export default App;

getdom.gif

控制台中输出的内容是init data,证明它确实已经被挂载到了DOM中去。这也就印证了官网中的那句话:componentDidMount() 会在组件挂载后(插入 DOM 树中)立即调用

function component

那么自从react hooks出来之后函数组件已经变得非常常见了,而useEffect也是使用频率非常高的一个hooks,我们平时可能就会将它类比为componentDidMount, 其实这两个并不是完全相等的,从我们刚刚的例子可以知道,componentDidMount其实会阻塞页面渲染,但是useEffect是一个异步执行的过程,也就是说会在真正渲染完成之后执行,并不会阻塞渲染。真正和componentDidMount等效的是useLayoutEffect,它和componentDidMount一样也会阻塞渲染。更加具体的内容可以参考我之前的一篇文章:useEffect和useLayoutEffect的区别

写在最后

所以,componentDidMount 其实是一个会阻塞渲染的生命周期,我们在这里面最好不要去执行一些非常耗时的逻辑,这样会让我们的首屏出现的更慢。这个细节可能很多人都注意不到,也有可能注意到了但是不明白为什么,其实这些东西官网上都给我们写的非常清楚了,多读几遍官网,可能你每次都会有不一样的收获。