前言
React Hooks从16.8.0版本 正式推出到现在已经两年多了,相信每个开发者都已经入坑,大部分也在广泛使用了。
但单就观察我们公司几个团队中的代码,发现用法千奇百怪,有不少错用、滥用的情况,理解的不是很透彻。我整理了一些供大家鉴赏,避免踩坑;
常见代码问题
示例1:Hooks 函数顺序
我们都知道 React Hooks 在重渲染时是依赖于固定顺序调用的。
在一个函数组件内,可以随意调用多个hooks api;
const Demo = () => {
const [a,setA] = useState('aaa');
const [b, setB] = useState('bbb');
// useState用在了条件语句中
if(type) {
const [c, setC] = useState('bbb');
}
return <div>组件示例</div>
}
**
当然在官网调教下,一般不会有人写上面的这种代码。但却有一部分人会写出下面这样的代码:
// 简化版
const Demo1 = (props) => {
const renderInfo = () => {
const {name} = props || {};
// ...逻辑代码
return useMemo(() => {
return (
<div>姓名:{name}
{/* <Component1 /> */}
</div>
)
},[]);
};
return (<div>
<h4>组件示例</h4>
<p>用户信息</p>
{renderInfo()}
</div>)
}
_
本意是在一个复杂的组件中,沿用class组件时的拆分思维,通过分块拆分的方式提取renderInfo,且想当然地运用useMemo
来减少内部的重渲染;乍一看没啥问题,代码运行也正常。
但其实存在两个大问题:
1. useMemo
优化根本没起到作用;组件重渲染,renderInfo
函数其实也是重新创建的。
2. 引入了隐藏bug;
新来的同事小王,接到新需求:在游客模式下,不展示该内容;于是很自然地添加了如下代码:
const Demo1 = (props) => {
const renderInfo = () => {
const {name} = props || {};
// ...逻辑代码
return useMemo(() => {
return (
<div>姓名:{name}
{/* <Component1 /> */}
</div>
)
},[]);
};
return (<div>
<h4>组件示例</h4>
{props.type!=='visitor' && (<><p>用户信息</p>{renderInfo()}</>)}
</div>)
}
这样的代码,在type='visitor'时
,就会导致React处理失败而崩溃退出;特别是在复杂组件中,冷不丁藏这么一段错误代码,不知道这颗炸弹什么时候就炸了。
上面的这段代码就是违背了hook使用的规则之一:只能在函数组件顶层使用 hook api;
示例2:在异步、settimeout等函数中,更新多个状态数据,不会合并更新问题;
重复渲染问题;
React是有批量更新机制的。即正常情况下,Class组件中多个setState或Function组件中多个useState更新,会被合并成一个操作;减少不必要的重复渲染。而在函数式组件中,同样存在更新多个setState,合并更新,触发一次重渲染的优化策略。
举个栗子:
export default function App() {
const [a, setA] = useState("");
const [b, setB] = useState("");
const [c, setC] = useState("");
const numRef = useRef(0);
console.log(`执行渲染次数:${numRef.current}`);
const onClick = () => {
setA((old) => old + "a");
setB((old) => old + "b");
setC((old) => old + "c");
numRef.current += 1;
};
return (
<div className="App">
<h1>Hello 开发者!</h1>
<br />
<div>
<Button type="primary" onClick={onClick}>
点击按钮
</Button>
</div>
</div>
);
}
例子-传送门
上面的例子中,按钮点击后更新了三个state,但重渲染只触发了一次。
图例:
不过,如果在异步、settimeout等函数中,却是另一番景象:
export default function App() {
const [a, setA] = useState("");
const [b, setB] = useState("");
const [c, setC] = useState("");
console.log(`组件渲染`, a, b, c);
// 异步代码中更新多个state
// const onClick = async () => {
// await 1;
// setA((old) => old + "a");
// setB((old) => old + "b");
// setC((old) => old + "c");
// };
// setTimemout中更新多个state
const onClick = () => {
setTimeout(() => {
console.log("setTimemout");
setA((old) => old + "a");
setB((old) => old + "b");
setC((old) => old + "c");
}, 1000);
};
return (
<div className="App">
<h1>Hello 开发者!</h1>
<br />
<div><Button type="primary" onClick={onClick}>点击按钮</Button></div>
</div>
);
}
例子-传送门
点击按钮 触发了多次组件渲染,react并没有合并更新;
图例:
那么区别和产生的原因是什么?
上面两个例子的区别就在于异步、setTimeout等中使用,js引擎把更新操作放入到了EventLoop异步队列中执行了。React无法对后续操作主动介入合并,只是做了被动一一执行。
然而,在实际项目开发中,我们经常需要在async await
或Promise
等异步回调中执行更新state的操作。在更新多个state且对多个state作为依赖项执行副作用操作的时候,就要比较小心了。
useEffect(() => {
// 请求接口数据
fetch({a,b,c});
},[a,b,c]);
上面的代码中,a、b、c变更都会触发请求,导致产生了两次多余请求,且接口处理快慢不一致,还易导致页面数据错乱。
上面只是一种常见场景,实际开发中对多个依赖项执行同一个副作用的场景很多,且更加复杂。没有处理好则很容易引起bug;
处理的方式有:
- 自行处理好更新多个state的先后关系,且副作用的执行增加限制条件;
- 使用useReducer收拢这些有依赖关系的state变更;
示例3:引用类型更新 浅比较问题
浅比较问题 setState ;在复杂数组对象变更时,引发的问题
var a =[1,2,3]
a.push(4)
setA(a);
其实在react里useEffect\useCallback\useMemo等依赖项都是浅比较;这一点要注意了!对于复杂对象,如果只用到了某些属性,则依赖项完全可以只添加对应的属性:
useEffect(()=>{
...
},[info.name, info.age])
示例4:异步更新-竞态问题;
比如,页面中多场景变更 都会 触发同一异步请求去更新数据。如果第二次异步请求比第一次异步请求先返回,就会发生竞态的问题。页面渲染出不匹配的数据。
其中一种解决竞态问题的方式就是加入一个标识:
代码:
useEffect(() => {
let isCancel = false; // 取消异步请求处理 状态
// 异步获取数据
const qryData = async () => {
try {
const params = {a, b};
const res = await fetch({ url: API_MESSAGE, payload: params });
if (!isCancel) {
// 存在竞态,则不更新数据。 否则更新数据
curDispatch({ type: 'list-data', payload: list || [] });
}
} catch (err) {
console.warn('接口处理失败,', err);
}
};
qryData();
return () => {
isCancel = true;
};
}, [a, b]);
useEffect 依赖项问题;
关于useEffect、useCallback的依赖项的不当使用,是项目中很大部分的bug来源。
这里提几个准则:
- 关于依赖项不要对React撒谎;添加全部依赖项;
官方文档 也要求我们把effect中使用到的数据流都放入到依赖项中,包括state、props和组件内函数。
- 当添加的依赖项过多,比如十几个时,就得反思自己的状态拆分和组件拆分是否不合理了。
具体Hook API使用遇到的一些场景
useState
- 为了减少团队开发中其他开发者的的理解成本,useState变量放到函数组件 顶部;且最好增加注释;
- 尽量把state往上层组件提取,公共状态提取到公共父组件中;
- 任何数据,都要保证只有一个数据来源,而且避免直接复制它,也不要随意派生state。很多场景可以用传递props、useMemo解决。
- state拆分粒度:
- 当state状态过多,或state有关联变动时。可以根据数据状态的相关联性放到一个state对象里。
- 复杂状态的处理方式更推荐使用:useReducer;
- 页面里定义了一堆的state状态;
- 状态数据之间有联动变更的操作’比如:a改变,需要变动b、c;
useEffect
我们使用 useEffect
完成副作用操作;是最常用的Hook API 之一。
useEffect依赖项问题
React中使用useEffect完成副作用操作,赋值给useEffect的函数会在每轮渲染结束后且传入的依赖项变更时才执行。
前文提到过:
函数组件首先是个普通函数,每一次渲染都是函数执行一遍。函数每一次执行都会生成本次独有的执行上下文, 相对应的,React重新渲染组件时都有它自己独立的变量及函数,包括Props和State 以及它自己的事件处理函数。其次React HooksAPI 赋予了函数内被HooksAPI包裹的某些变量独特的意义:缓存值和函数、值变更触发重渲染等。
Effect就属于某一个特定的渲染,并且每个effect函数“看到”的props和state都来自于它属于的那次特定渲染。而effect依赖项决定传入的函数是否被执行。
所以为了保证effect内获取到正确的props和state值,添加全部依赖特别重要。
不要试图欺骗React,可能会引发bug;
这个在官网中有说明:在依赖列表中省略函数是否安全?
闭包导致变量获取不及时-链接
useCallback
useMemo, useCallback是作为性能优化的方式存在,不要作为阻止渲染的语义化保证;
即对于组件内定义的常规函数,没必要全都用 useCallback 包裹,滥用反而会引入一些奇怪的bug。
原则是:不清楚是否要用就先都不用;
另外有以下几种场景,是有助于性能改善的:
1. 减少子组件的非必要重渲染;
// 子组件
const Child = memo((props:any) => {
console.log('子组件渲染...');
return (<div>子组件渲染...</div>);
});
// 父组件
const Parent = () => {
const [info, setInfo] = useState({});
const [count, setCount] = useState(0);
const changeName = () => {
console.log('更改信息了...');
};
return (
<div className="App">
<div>标识: {count}</div>
<div onClick={() => { setCount(count => count + 1); }}>点击增加</div>
<Child info={info} changeName={changeName}/>
</div>
);
};
export default Parent;
比如,在上方的例子中, count
值变更,Parent组件重新渲染,却会触发Child子组件重新渲染。原因就是父组件中重新执行,重新生成新的changeName函数传入子组件,子组件props变更,触发重渲染。
在常规用法中,这样也没什么问题。但在性能要求高或子组件内重渲染代价过高等场景中,就得避免这样非必要的重渲染,解决方式就是修改父组件的 changeName 方法,用 useCallback 钩子函数包裹一层。
- 子组件把props中传入的函数作为effects等的依赖项,这时不加useCallback容易造成死循环等bug; ```javascript / bad case / let count = 0;
function Child({val, getData}) { useEffect(() => { getData(); }, [getData]); return
function Parent() {
const [val, setVal] = useState(‘’);
function getData() {
setTimeout(() => {
setVal(“new data “ + count);
count++;
}, 500);
}
return
export default Parent;
在上方的代码中,Child子组件里useEffect根据getData获取数据。但实际情况是Parent父组件中每次val变更触发重渲染 getData都是重新生成,会造成死循环。<br />**解决方式**就是:使用useCallback包裹getData函数,达到缓存getData引用的目的。
<a name="DajKR"></a>
### useRef
一般,useRef有两个使用场景:
<a name="bHDT1"></a>
#### 1. 指向组件dom元素
a. 获取组件元素的属性值;<br />b. 用以操作目标指向dom的api,如下方例子中的指向一个 input 元素,并在页面渲染后使 input 聚焦;
```javascript
const Page = () => {
const myRef = useRef(null);
useEffect(() => {
myRef.current.focus(); // 目标input聚焦
});
return (
<div>
<span>UseRef:</span>
<input ref={myRef} type="text"/>
</div>
);
};
export default Page1;
2. 存放变量
可以保存任何可变值,且值不会进入依赖收集项内;
类似于class组件使用实例字段的方式,类似于this,在重渲染时,每次都会返回相同的引用;
const Page = () => {
const myRef = useRef(0);
const [list, setList] = useState([])
const onDelClick = () => {
}
const onAddClick = () => {
}
return (
<div>
<div onClick={()=> setCount(count+1)}>点击count</div>
<div onClick={()=> setCount(count+1)}>点击count</div>
<div onClick={()=> handleClick()}>查看</div>
</div>
);
export default Page;
useMemo用法鉴赏
useMemo使用目的的不同,可以分为以下几个场景:
缓存复杂计算值,减少不必要的状态管理;
export default Demo = ({info}) => {
const money = useMemo(() => {
// 计算 渲染值
const res = calculateNum(info.num);
return res;
},[info.num]);
return <div>价格是:{money}</div>
}
如上面的这段代码,money字段可以通过useMemo缓存,只有
info.num
变更才会重新计算,减少不必要计算的同时还可以避免维护不必要的派生state;缓存部分jsx或组件,避免不必要的渲染;
export default Demo = ({info}) => {
const topEl = useMemo(() => (
<div>
<p>用户信息</p>
{/* 渲染用户数据... */}
</div>
),[info.user]);
return <div>
{topEl}
{/* 渲染列表数据... */}
</div>
}
上面的这段代码,一来可以通过在部分状态数据不变时,缓存对应的jsx;一来可以适当拆分复杂逻辑,使组件更简洁;
当然处理逻辑复杂到一定程度,还是推荐抽离成独立组件,并通过memo
包裹子组件;
错误用法:
你可以把
useMemo
作为性能优化的手段,但不要把它当成语义上的保证。
示例1:
const Demo = () => {
const [name, setName] = useState(undefined);
const [copyNum, setCopyNum] = useState(0);
// 控制展示值
const topEl = useMemo(() => (
<div>复制的值是:{name}</div>
), [copyNum]);
return (
<div className="page-demo">
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="请输入名称" />
<Button onClick={() => setCopyNum((old) => old+1)}>复制</Button>
{topEl}
</div>
);
};
上面的这个简化版的例子中,本意是点击“复制”按钮的时候,复制输入框中当前的值。
代码中,希望通过useMemo来控制展示结果,topEl中用到了name,却没有添加到依赖项中,只有点击按钮,copyNum变更,展示的内容name才会变更。
看起来处理没问题,但却把useMemo用错了地方。即把useMemo用来控制渲染结果,对结果值进行了语义上的保证,而不是优化性能的目的。
这会带来什么问题呢?造成状态值与渲染值的不匹配,造成混乱,还容易引起bug。滥用useMemo;
const Demo = () => {
const [name, setName] = useState(undefined);
const [copyNum, setCopyNum] = useState(0);
// 控制展示值
const topEl = useMemo(() => <div>复制的值是:{name}</div>, [name]);
return useMemo(() => (
<div className="page-demo">
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="请输入名称" />
<Button onClick={() => setCopyNum((old) => old + 1)}>复制</Button>
{topEl}
</div>
),[name, age, num,topEl,...]);
};
上面的例子,我在项目发现不少开发同学这么写, 本意是对整个组件的
return jsx
都进行缓存优化。但存在几个问题:
1. 组件复杂之后,依赖项过多,每增加一个状态或useMemo、useCallback
都得手动加入到依赖项中。增加不必要的维护成本和出错概率。
2. 组件执行重渲染,就是希望有相应的jsx,而不是对整个组件的返回做这种语义化的缓存,一来对于整个组件做状态变更缓存,相当于没做。对于需要优化缓存的部分,可以提取成每个独立的uesMemo部分;return只做组合。
若遇到组件改造,需加入组件提前返回,减少子组件渲染的情况,则直接就引起了bug;例:
...
if (loading) {
return <Loading />
}
return useMemo(() => (<div>...</div>), [name, ...]);
比较好的处理逻辑是 在的确需要优化,避免子组件不必要的重渲染的场景下,根据实际业务逻辑,拆分成多个useMemo缓存:
// 根据页面功能模块拆分,处理成的不同逻辑单元;
const topEl = useMemo(()=>(<div>...</div>),[topInfo]);
const userEl = useMemo(()=>(<div>...</div>),[userInfo]);
if (loading) {
return <Loading />
}
return (<div>
{topEl}
...
{userEl}
</div>);
当然 如果依赖项还是过多,则就要考虑使用useReducer收拢状态了。
逻辑复杂的组件还是要优先考虑 拆分成子组件;
useReducer的妙用
不要害怕使用useReducer
- 它没有你想的那么深奥,学会了可以解决不少实际问题;
- 在源码实现中,大量使用了reducer、dispatch的相关知识;本质上useState和useReducer的实现原理是差不多的。可以把useState理解为特殊的useReducer;与useReducer的区别是,为useState提供了一个预定义的reducer处理程序;
实际上useState返回的结果是一个reducer状态和一个action dispatcher;
使用举例: 待补充…
后语
前言要搭后语
概念理解的越透彻,使用就越顺畅。
我目前在做的就是在持续学习当中总结自己在实践React过程中的所见所学。
本篇文章主要是React Hooks相关知识,涉及的代码优劣在下一篇会专项探讨;