React 18 中StrictMode多次调用函数式组件体及其hook

2,499 阅读8分钟

前言:用create-react-app脚手架搭建react项目,跟着视频走,发现对不上,折腾了半天,发现两天前已经更新了react18,所以脚手架默认直接搭建的就是18版本的,结果初次挂载会把组件运行两次,我以为是bug,就去提issue,结果回复说是新特性

  • React version:18.1.0

问题背景

如上文所述,直接脚手架搭建项目,其中场景会用到fetch异步请求一些数据,然后通过函数式组件响应到页面,在试图找到问题所在的前提下,进行简化,先用同步代码测试,已经发现会重复调用函数体。但是在react 17 以及之前的版本就不会出现这种情况,所以我以为是bug,找了好久,因为是两天前才更新的18.1.0,网上也没讲这个的帖子,就去提了issue。

issue地址:https://github.com/facebook/react/issues/24467 (随后被告知是新特性,已被关闭)

再现步骤:

  1. create-react-app test-render --template typescript
  2. go to src/App.tsx write useEffect( ) in the function before return, such as console.log('app') in the callback function
  3. npm run start
  4. checkout the console, you will find this function runs two times
  5. if I use the React 17 ,there will be only once print

再现测试代码 Link to code example: github.com/voiceu-zuix…

image.png

得到的结果:发现不管是组件函数体内还是useEffect内,都运行了两边

image.png

探寻答案

根据issue中的回复链接,找到了如下文档资料,基本都是官方网站或者官方github

资料 1:reactjs.org/blog/2022/0…

资料 2:github.com/reactwg/rea…

资料 3:github.com/reactwg/rea…

资料 4.1:zh-hans.reactjs.org/docs/strict…

资料 4.2(资料 1 的中文文档片段):zh-hans.reactjs.org/docs/strict…

资料 5(问题原因所在)zh-hans.reactjs.org/docs/strict…

总的来说,是入口文件index.tsx<React.StrictMode>导致,新特性在 资料5 中提到如下:


渲染阶段的生命周期包括以下 class 组件方法:

  • constructor
  • componentWillMount (or UNSAFE_componentWillMount)——这个导致我项目中
  • componentWillReceiveProps (or UNSAFE_componentWillReceiveProps)
  • componentWillUpdate (or UNSAFE_componentWillUpdate)
  • getDerivedStateFromProps
  • shouldComponentUpdate
  • render
  • setState 更新函数(第一个参数)

因为上述方法可能会被多次调用,所以不要在它们内部编写副作用相关的代码,这点非常重要。忽略此规则可能会导致各种问题的产生,包括内存泄漏和或出现无效的应用程序状态。不幸的是,这些问题很难被发现,因为它们通常具有非确定性

严格模式<React.StrictMode>不能自动检测到你的副作用,但它可以帮助你发现它们,使它们更具确定性。通过故意两次调用(double-invoking) 以下函数来实现的该操作:

  • class 组件的 constructor,render 以及 shouldComponentUpdate 方法
  • class 组件的生命周期方法 getDerivedStateFromProps
  • 函数组件体 (所以正是因为这条,才导致的)
  • 状态更新函数 (即 setState 的第一个参数)
  • 函数组件通过使用 useState,useMemo 或者 useReducer(还有这条)

我的项目简化的代码如下,整体意思是:调用Test组件函数体就会打印Test,调用useEffect就会打印res

import { useEffect, useState } from 'react'
const apiUrl = process.env.REACT_APP_API_URL

export const Test = () => {
  const [param, setParam] = useState({ name: '' })
  console.log('Test')
  
  useEffect(() => {
    fetch(`${apiUrl}/projects?${param}`).then(async (response) => {
      if (response.ok) {
        let res = await response.json()
        console.log('res', res)
        setParam(res)
      }
    })
    
    console.log('useEffect')
  }, [])
  
  return <div>我是Test</div>
}

打印结果:

image.png

结合上面的答案,可以知道我的代码,虽然是函数式组件,但是如果是类式组件构建的话,就会经历上面的一系列生命周期,在严格模式<React.StrictMode>下,这些周期方法就会被多次调用,但是却是函数式组件,所以React模拟这种情况,就多次调用函数体组件和setState,具体多少次,按官方说的,应该是两次,但是却出现了不一样的情况,这一点我也有测试,多数情况是两次,少数情况不一致,可能是由于我的异步请求的原因吧,实在是找不到原因。

也出现过如下情况

image.png

为了看清楚这个步骤,我尝试了在外部加一个变量,渲染一次后就+1

import { useEffect, useState } from 'react'
const apiUrl = process.env.REACT_APP_API_URL

let n = 1
export const Test = () => {
  const [param, setParam] = useState({ name: '' })
  console.log('Test', n)

  useEffect(() => {
    fetch(`${apiUrl}/projects?${param}`).then(async (response) => {
      if (response.ok) {
        let res = await response.json()
        console.log('res', res)
        setParam(res)
      }
    })

    console.log('useEffect', n)
  }, [])

  n += 1
  console.log('n',n)

  return <div>我是Test</div>
}

结果如下:

image.png

分析其过程

  • 已知严格模式下,会两次调用函数组件体以及 useState hook,其余的规则与此代码无关
  • 此处的useEffect,第二个参数是空数组,模拟的是componentDidMount生命周期,是提交阶段,不受影响
  • 所以整体流程应该是,在渲染阶段,两次调用函数体和setState,在提交阶段,又因为是函数体

具体流程如下:

  • 第一次挂载,第一次调用函数体,输出,然后卸载

image.png

  • 第二次挂载,第二次调用函数体,输出,然后准备进入componentDidMount生命周期

image.png

  • 此时准备调用前两次的useEffect,所以n都是3

image.png

  • 第一个useEffect内部调用useStateuseParam,所以会两次调用该函数,该函数会导致页面出现渲染,所以函数组件体又被两次调用

  • 第二个useEffect同理。

如果是类式组件的话,componentDidMount函数应该不会被调用两次,因为是函数式组件的整体调用,才让useEffect被迫调用两次,下面进行测试


import React, { Component } from 'react'

let number_outside = 1

export default class TestClass extends Component {
  constructor() {
    super()
    console.log('constructor', number_outside)
    this.state = {
      param: {
        name: '',
        num: 10
      }
    }
  }

  componentDidMount() {
    this.setState({ param: { num: this.state.param.num + 1 } })
    console.log('didMount-同步', number_outside, this.state.param.num)
  }
  onClick = () => {
    this.setState({ param: { name: '22' } })
  }

  componentDidUpdate() {
    console.log('didUpdate-同步', number_outside,this.state.param.num)
  }

  render() {
    number_outside += 1
    console.log('Test', number_outside)

    return <div onClick={this.onClick}>test-class</div>
  }
}

结果如下,componentDidMount函数被调用两次,但是内部的setState并没有生效两次,只生效一次,很费解,不过测试了所有打包后的生产环境,均无这些情况,很正常。

image.png

但是这种开发模式下的多次调用的情况,导致我想进行打印输出来判断哪里出了bug,可能会出现大问题,不太想用这种模式,看到了可以通过ref来解决

image.png

const didLogRef = useRef(false);

useEffect(() => {
  // In this case, whether we are mounting or remounting,
  // we use a ref so that we only log an impression once.
  if (didLogRef.current === false) {
    didLogRef.current = true;

    SomeTrackingAPI.logImpression();
  }
}, []);

总结

尽量别在上面提到的函数内部写effect相关的代码吧,我在想要是有状态库,这些反复调用的话,会不会把状态反复操作了,比如+1,在测试里是通过state来进行,确实没有出问题,但是函数式组件例子里在外部的一个变量n却实实在在的改变了,因为函数体和useState的存在,还不止改变了2次,所以担心redux这些会不会有影响,比如我在刚刚的例子里不是操作n+1,而是想在初次渲染就让redux里的某个值+1,这在开发环境下,怕不是直接加了好几次。

确实在测试中componentDidMount也会跑两次,但是componentDidUpdate只跑一次,而且两者都没被写进那个list里面,然后我就又去那个issue底下问了一下,回复我如下

image.png

所以,还是不要纠结这个了,估计后面的版本会陆续完善,毕竟18才刚出,他说他也觉得应该把这个加进docs去,后面看看会不会加吧

然后马上又回复了,效率是真高,

image.png

说已经委婉的提出了,但是没有明说,我们可以把这个放到具体的提示里面

But we can explicitly list what APIs are included in this to help discoverability (e.g. by using STRG+F "componentDidMount"). Opened reactjs/reactjs.org#4618 for that.

github.com/reactjs/rea…

image.png

github.com/reactjs/rea…

image.png

尝试了不用严格模式,直接写render(<App>),这样hook确实不会被重复调用,但是useState还是会把函数组件体外部的逻辑走一遍,会绕开hook,不懂为什么。

类式组件在不是严格模式下不会有问题,因为类式组件想要在内部写一点渲染就能运行的逻辑,必须是在生命周期钩子中,比如constructor,render这些函数里,些其他函数只能是定义,而无法运行,但是函数式组件就可以直接写,因此感觉最好是除了hook这些函数,就避免写直接运行的函数,毕竟是组件,还是写一些函数的定义,在触发的时候运行,这样就不会有bug了

解决办法

所以react 17的时候严格模式不会render2遍,react 18会,并且尽量不要在函数式组件里写直接运行的函数逻辑,最好是定义函数 xxx=()=>{ ? ? ? },而不是写 xxx(),不用严格模式就好

import ReactDOM from 'react-dom/client'
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
root.render(
  // 之前的代码都是直接包裹路由,也没用严格模式,问题不大,只是少一些提醒
  // react 17的时候严格模式不会render2遍,react 18会
  <App />
  //不用严格模式
  // <React.StrictMode>
  //   <App />
  // </React.StrictMode>
)

后记

参考 文章中的,我去检查了自己的react-dev-tool版本,发现是2020年添加的,所以更新到最新版之后,发现二次渲染的打印输出,确实变成了灰色。

image.png

image.png