这是在项目开发过程中使用react的function component和hooks遇到的问题, 平时在写代码的过程中, 整个页面级别的文件我会选择使用class的形式书写, 而在功能或者说逻辑相对简单, 比如纯展示型的组件, 我就会选择使用function component同时辅以hooks
Rendered more hooks than during the previous render
在描述项目的实际情况之前我们一起来看一个接地府地气的demo, 最近全国各地都在展开疫苗的接种, 我也在清明假期期间接种了第一剂疫苗, 接种之前需要填表, 里面有一项是是否处于妊娠期, 男女都要填, 但如果以一个电子表单的形式来处理, 那么可以在性别为女的时候才显示, 性别为男的时候则不显示, 代码如下:
import React, { Fragment, useState } from 'react';
const Vero = () => {
const [ gender, setGender ] = useState('male');
const [ isUnder18, setIsUnder18 ] = useState(0);
const [ isOver60, setIsOver60 ] = useState(0);
if(gender === 'male') {
return (
<Fragment>
<div>
<div>18岁以下:</div>
<div>
是:
<input
type="radio"
value={1}
checked={isUnder18}
onChange={
e => {
const { target } = e;
const { value } = target;
setIsUnder18(+value);
}
}
/>
</div>
<div>
否:
<input
type="radio"
value={0}
checked={!isUnder18}
onChange={
e => {
const { target } = e;
const { value } = target;
setIsUnder18(+value);
}
}
/>
</div>
</div>
<div>
<div>60岁以上:</div>
<div>
是:
<input
type="radio"
value={1}
checked={isOver60}
onChange={
e => {
const { target } = e;
const { value } = target;
setIsOver60(+value);
}
}
/>
</div>
<div>
否:
<input
type="radio"
value={0}
checked={!isOver60}
onChange={
e => {
const { target } = e;
const { value } = target;
setIsOver60(+value);
}
}
/>
</div>
</div>
<div>
<div>性别:</div>
<div>
男:
<input
type="radio"
value={'male'}
checked={gender === 'male'}
onChange={
e => {
const { target } = e;
const { value } = target;
setGender(value);
}
}
/>
</div>
<div>
女:
<input
type="radio"
value={'female'}
checked={gender === 'female'}
onChange={
e => {
const { target } = e;
const { value } = target;
setGender(value);
}
}
/>
</div>
</div>
</Fragment>
);
}
const [ gestation, setGestation ] = useState(1);
return (
<Fragment>
<div>
<div>18岁以下:</div>
<div>
是:
<input
type="radio"
value={1}
checked={isUnder18}
onChange={
e => {
const { target } = e;
const { value } = target;
setIsUnder18(+value);
}
}
/>
</div>
<div>
否:
<input
type="radio"
value={0}
checked={!isUnder18}
onChange={
e => {
const { target } = e;
const { value } = target;
setIsUnder18(+value);
}
}
/>
</div>
</div>
<div>
<div>60岁以下:</div>
<div>
是:
<input
type="radio"
value={1}
checked={isOver60}
onChange={
e => {
const { target } = e;
const { value } = target;
setIsOver60(+value);
}
}
/>
</div>
<div>
否:
<input
type="radio"
value={0}
checked={!isOver60}
onChange={
e => {
const { target } = e;
const { value } = target;
setIsOver60(+value);
}
}
/>
</div>
</div>
<div>
<div>性别:</div>
<div>
男:
<input
type="radio"
value={'male'}
checked={gender === 'male'}
onChange={
e => {
const { target } = e;
const { value } = target;
setGender(value);
}
}
/>
</div>
<div>
女:
<input
type="radio"
value={'female'}
checked={gender === 'female'}
onChange={
e => {
const { target } = e;
const { value } = target;
setGender(value);
}
}
/>
</div>
</div>
<div>
<div>妊娠期:</div>
<div>
是:
<input
type="radio"
value={1}
checked={gestation}
onChange={
e => {
const { target } = e;
const { value } = target;
setGestation(+value);
}
}
/>
</div>
<div>
否:
<input
type="radio"
value={0}
checked={!gestation}
onChange={
e => {
const { target } = e;
const { value } = target;
setGestation(+value);
}
}
/>
</div>
</div>
</Fragment>
);
};
export default Vero;
当然了, 我只是举个例子, 毕竟我们用react进行开发, 这么样的一个需求, 无论如何也不会这么写, 毕竟任何技能你熟练到一定程度, 想犯错反而是个比较困难的事, 这不是凡尔赛哈, 我想写这个报错的例子也还是想了一下才写出来的...
咳...咳...言归正传, 此时我们修改gender, 那么页面就会报错了:
React has detected a change in the order of Hooks called by Vero. This will lead to bugs and errors if not fixed. For more information, read the Rules of Hooks:Rules of Hooks
react检测到了Vero调用hook的顺序发生了改变, 如果不修复将会导致一些问题和错误, 详情请查阅hooks的规则
Rendered more hooks than during the previous render.
比上一次多渲染了些hooks
那么我来看一看, 到底怎么就比上一次多渲染了, 首先, 程序渲染的是3个hooks:
const [ gender, setGender ] = useState('male');
const [ isUnder18, setIsUnder18 ] = useState(0);
const [ isOver60, setIsOver60 ] = useState(0);
此时由于if判断, 最终我们的函数式组件返回的jsx渲染结果为:
18岁以下
60岁以上
性别
然后我们修改了性别state: gender, 将它的值从初始值male修改为了female, 那么此时Vero函数重新执行, 最终渲染结果为:
4个hooks:
const [ gender, setGender ] = useState('male');
const [ isUnder18, setIsUnder18 ] = useState(0);
const [ isOver60, setIsOver60 ] = useState(0);
const [ gestation, setGestation ] = useState(1);
if条件不再满足, 不执行, 渲染结果为:
18岁以下
60岁以上
性别
妊娠期
但程序报错, 最终的渲染结果没能正常显示出来, 此时我们发现:
上一次渲染了3个hooks:
const [ gender, setGender ] = useState('male');
const [ isUnder18, setIsUnder18 ] = useState(0);
const [ isOver60, setIsOver60 ] = useState(0);
接下来渲染了4个hooks:
const [ gender, setGender ] = useState('male');
const [ isUnder18, setIsUnder18 ] = useState(0);
const [ isOver60, setIsOver60 ] = useState(0);
const [ gestation, setGestation ] = useState(1);
确实比上一次多, 多1个
const [ gestation, setGestation ] = useState(1);
这是怎么回事呢?让我们来一起看看报错信息里提到的文档:
Only Call Hooks at the Top Level
Don’t call Hooks inside loops, conditions, or nested functions. Instead, always use Hooks at the top level of your React function, before any early returns. By following this rule, you ensure that Hooks are called in the same order each time a component renders. That’s what allows React to correctly preserve the state of Hooks between multiple useState and useEffect calls. (If you’re curious, we’ll explain this in depth below.)
只在顶部调用hooks
不要在循环, 条件或者嵌套函数中调用hooks. 相反, 总是在你react函数的顶部, 在过早的return语句之前使用hooks. 通过遵守这个规则, 你就能确定每次渲染的时候hooks都能按照相同的顺序被调用, 这就是为何react能在多个useState和useEffect的调用中正确保持hooks的状态的原因(如果你好奇, 我们在后面的文章中会有一个解释)
查阅这个解释之后发现答案是:
React relies on the order in which Hooks are called
react依赖hooks的调用顺序
Our example works because the order of the Hook calls is the same on every render
我们的例子能够正常运行是因为每次渲染hook的渲染顺序都是一致的
回到我们自己的例子中, 之所以报错是因为顺序被打乱了, 一开始调用的hooks的渲染我们记作Previous render, 此时渲染的hooks如下:
const [ gender, setGender ] = useState('male');
const [ isUnder18, setIsUnder18 ] = useState(0);
const [ isOver60, setIsOver60 ] = useState(0);
然后我们修改了了性别这个state之后出发了重新渲染, 这次渲染我们记作Next render, 此时if判断不成立, 代码运行if之后的语句, 于是渲染的hooks如下:
const [ gender, setGender ] = useState('male');
const [ isUnder18, setIsUnder18 ] = useState(0);
const [ isOver60, setIsOver60 ] = useState(0);
const [ gestation, setGestation ] = useState(1);
Next render比Previous render多渲染了1个hook:
const [ gestation, setGestation ] = useState(1);
在报错的结果中其实也有显示这两次的渲染差异:
我们的代码导致了前后hooks渲染的不一致, 此时react就没法依赖hooks的调用顺序了, 因为两次的不一致了, 因此就报错了
Rendered fewer hooks than expected. This may be caused by an accidental early return statement
有Rendered more hooks的错自然也就有Rendered fewer hooks的错, 我们把一开始的代码做一个修改, 只改一个地方, 其他保持不变:
if(gender === 'male')
改为:
if(gender === 'female')
同样, 修改我们的性别这个state, 此时react会给我们报如下的错误:
Rendered fewer hooks than expected. This may be caused by an accidental early return statement.
为何这回是fewer而不是more了呢?我们根据上面的知识来看看, 首先Previous render, 因为if的条件修改了之后if里面的语句无法被执行, 直接渲染出了带妊娠期的jsx, 也就是此次渲染渲染了4个hooks:
const [ gender, setGender ] = useState('male');
const [ isUnder18, setIsUnder18 ] = useState(0);
const [ isOver60, setIsOver60 ] = useState(0);
const [ gestation, setGestation ] = useState(1);
而当我们修改了性别这个state触发重绘的时候, 此时if条件被满足了, 执行了if里面的语句, 后面的语句不再执行, 此时Next render就只执行到if语句里面的return的位置, 因此这回只渲染了3个hooks, 也就是顶部的那3个hooks:
const [ gender, setGender ] = useState('male');
const [ isUnder18, setIsUnder18 ] = useState(0);
const [ isOver60, setIsOver60 ] = useState(0);
我们可以自己写一下Previous render和Next render, 这样会有一个更加直观的比较:
| Previous render | Next render |
|---|---|
| gender | gender |
| isUnder18 | isUnder18 |
| isOver60 | isOver60 |
| gestation | undefined |
这样就能直观的看到前后两次渲染中hooks被渲染的情况了
当然了, 这个需求如果真的实现起来, 正确的写法是这样的:
import React, { Fragment, useState } from 'react';
const Vero = () => {
const [ gender, setGender ] = useState('male');
const [ isUnder18, setIsUnder18 ] = useState(0);
const [ isOver60, setIsOver60 ] = useState(0);
const [ gestation, setGestation ] = useState(1);
return (
<Fragment>
<div>
<div>18岁以下:</div>
<div>
是:
<input
type="radio"
value={1}
checked={isUnder18}
onChange={
e => {
const { target } = e;
const { value } = target;
setIsUnder18(+value);
}
}
/>
</div>
<div>
否:
<input
type="radio"
value={0}
checked={!isUnder18}
onChange={
e => {
const { target } = e;
const { value } = target;
setIsUnder18(+value);
}
}
/>
</div>
</div>
<div>
<div>60岁以下:</div>
<div>
是:
<input
type="radio"
value={1}
checked={isOver60}
onChange={
e => {
const { target } = e;
const { value } = target;
setIsOver60(+value);
}
}
/>
</div>
<div>
否:
<input
type="radio"
value={0}
checked={!isOver60}
onChange={
e => {
const { target } = e;
const { value } = target;
setIsOver60(+value);
}
}
/>
</div>
</div>
<div>
<div>性别:</div>
<div>
男:
<input
type="radio"
value={'male'}
checked={gender === 'male'}
onChange={
e => {
const { target } = e;
const { value } = target;
setGender(value);
}
}
/>
</div>
<div>
女:
<input
type="radio"
value={'female'}
checked={gender === 'female'}
onChange={
e => {
const { target } = e;
const { value } = target;
setGender(value);
}
}
/>
</div>
</div>
{
gender === 'female'
? (
<div>
<div>妊娠期:</div>
<div>
是:
<input
type="radio"
value={1}
checked={gestation}
onChange={
e => {
const { target } = e;
const { value } = target;
setGestation(+value);
}
}
/>
</div>
<div>
否:
<input
type="radio"
value={0}
checked={!gestation}
onChange={
e => {
const { target } = e;
const { value } = target;
setGestation(+value);
}
}
/>
</div>
</div>
)
: null
}
</Fragment>
);
};
export default Vero;
性别 state为male(也就是初次渲染)时:
当我们修改了性别 state为female时:
然后就可以愉快地去打疫苗了
在项目中的场景
在自己负责的一个项目中, 网络请求使用了graphql(apollo-client), 同时是ssr(nextjs)的项目, 里面还需要获取用户的地理位置, 这里使用了百度地图JavaScript API v3.0类参考_Geolocation, 这里顺便吐槽一下百度地图是真难用, 之前用过百度云存储, 文档不全, 连蒙带猜的使用, 功能提供不全, 还要去改源码, 醉了, 现在写文章的时候发现之前的定位demo找不到了, 就附上Geolocation这个类的文档
获取用户位置的操作是异步的, 以及我们需要在程序渲染到了客户端也就是浏览器的时候根据浏览器自带的定位接口定位, 失败了再走IP定位, 这个逻辑百度定位API帮我们封装好了, 我们直接使用即可, 但因为是异步的, 我们需要这么做, 流程如下:
- 先呈递一版页面给用户
- 获取用户坐标
- 在拿到定位之后请求后端接口重新获取数据
- 页面
loading - 请求结束取消
loading - 用新的数据重新渲染页面
页面使用了function component, 同时使用了hooks, 项目一直都很ok, 直到接入登录注册流程, 登录注册是另一个小伙伴写的, 他封装成了一个js文件, 也就是说这个js给window上加了一个object, 使用传统的方式, 就是传递登录注册框div的id的方式来使用, 我需要在程序渲染到浏览器的时候去访问window来拿到他封装的这个object从而初始化登录注册功能
查看上面的流程, 我需要在第6步之后做这个动作, 因为如果在获取坐标之前做这个操作, 如果用户同意获取坐标, 此时拿到了坐标重新渲染, 那么初始化之后的登录注册框将消失, 于是只能在第二次渲染页面也就是第6步之后来做:
if(loading) {
return <LoadingSpin />;
}
useEffect(
() => {
//初始化登录注册框
},
[]
)
这个时候就犯了上面的demo中的错了, 我在条件判断return之后使用了hook, 此时调用顺序会发生变化, 最终导致react报错, 但我如果将useEffect则会在获取到用户坐标的时候页面重新渲染而导致登录注册初始化失效, 此时我想到了一个方法: 既然获取到坐标再去请求数据的时候要渲染loading组件, 那么我在loading的componentWillUnmount生命周期中做初始化操作不就行了
说干就干, 移除上面这个在if return之后写的useEffect, 修改成如下的写法:
import { useEffect } from 'react';
import PropTypes from 'prop-types';
import { Spin } from 'antd';
const LoadingSpin = ({ containerStyle = {}, size, willUnmountCb }) => {
useEffect(
() => {
return () => {
if(willUnmountCb) {
willUnmountCb();
}
};
},
[]
);
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
...containerStyle
}}
>
<Spin size={size || 'default'}/>
</div>
);
};
LoadingSpin.propTypes = {
containerStyle: PropTypes.object,
size: PropTypes.string,
willUnmountCb: PropTypes.func
};
export default LoadingSpin;
这里的LoadingSpin就是非常典型的function component组件的一个例子, 纯展示型组件, 用fc再合适不过, 哪怕会加入一些state, 使用useState这些hooks也最合适不过了, 这里的useEffect:
useEffect(
() => {
return () => {
if(willUnmountCb) {
willUnmountCb();
}
};
},
[]
);
它的第一个参数是个函数, 该函数如果return了一个函数, 那么return的这个函数将在组件卸载之前执行, 也就是我们class组件的componentWillUnmount, 那么外部使用的时候给它传递初始化登录注册组件的方法即可:
if(loading) {
return (
<LoadingSpin
size="large"
containerStyle={{
width: '100vw',
height: '100vh'
}}
willUnmountCb={
() => {
//初始化登录注册
}
}
/>
);
}
这样的方式就不会破坏react的hooks的顺序, 同时也达到了我们的需求
如果你觉得这篇文章对你有用的话记得给我点个赞, 点个收藏, 众人拾柴, 愿没有难写的代码, 没有难实现的需求