前言

React Hooks从16.8.0版本 正式推出到现在已经两年多了,相信每个开发者都已经入坑,大部分也在广泛使用了。
但单就观察我们公司几个团队中的代码,发现用法千奇百怪,有不少错用、滥用的情况,理解的不是很透彻。我整理了一些供大家鉴赏,避免踩坑;

常见代码问题

示例1:Hooks 函数顺序

我们都知道 React Hooks 在重渲染时是依赖于固定顺序调用的。
在一个函数组件内,可以随意调用多个hooks api;

  1. const Demo = () => {
  2. const [a,setA] = useState('aaa');
  3. const [b, setB] = useState('bbb');
  4. // useState用在了条件语句中
  5. if(type) {
  6. const [c, setC] = useState('bbb');
  7. }
  8. return <div>组件示例</div>
  9. }

**
当然在官网调教下,一般不会有人写上面的这种代码。但却有一部分人会写出下面这样的代码:

  1. // 简化版
  2. const Demo1 = (props) => {
  3. const renderInfo = () => {
  4. const {name} = props || {};
  5. // ...逻辑代码
  6. return useMemo(() => {
  7. return (
  8. <div>姓名:{name}
  9. {/* <Component1 /> */}
  10. </div>
  11. )
  12. },[]);
  13. };
  14. return (<div>
  15. <h4>组件示例</h4>
  16. <p>用户信息</p>
  17. {renderInfo()}
  18. </div>)
  19. }

_
本意是在一个复杂的组件中,沿用class组件时的拆分思维,通过分块拆分的方式提取renderInfo,且想当然地运用useMemo来减少内部的重渲染;乍一看没啥问题,代码运行也正常。
但其实存在两个大问题:
1. useMemo优化根本没起到作用;组件重渲染,renderInfo函数其实也是重新创建的。
2. 引入了隐藏bug;
新来的同事小王,接到新需求:在游客模式下,不展示该内容;于是很自然地添加了如下代码:

  1. const Demo1 = (props) => {
  2. const renderInfo = () => {
  3. const {name} = props || {};
  4. // ...逻辑代码
  5. return useMemo(() => {
  6. return (
  7. <div>姓名:{name}
  8. {/* <Component1 /> */}
  9. </div>
  10. )
  11. },[]);
  12. };
  13. return (<div>
  14. <h4>组件示例</h4>
  15. {props.type!=='visitor' && (<><p>用户信息</p>{renderInfo()}</>)}
  16. </div>)
  17. }

这样的代码,在type='visitor'时,就会导致React处理失败而崩溃退出;特别是在复杂组件中,冷不丁藏这么一段错误代码,不知道这颗炸弹什么时候就炸了。

上面的这段代码就是违背了hook使用的规则之一:只能在函数组件顶层使用 hook api;

react批量更新机制

示例2:在异步、settimeout等函数中,更新多个状态数据,不会合并更新问题;

重复渲染问题;
React是有批量更新机制的。即正常情况下,Class组件中多个setState或Function组件中多个useState更新,会被合并成一个操作;减少不必要的重复渲染。而在函数式组件中,同样存在更新多个setState,合并更新,触发一次重渲染的优化策略。
举个栗子:

  1. export default function App() {
  2. const [a, setA] = useState("");
  3. const [b, setB] = useState("");
  4. const [c, setC] = useState("");
  5. const numRef = useRef(0);
  6. console.log(`执行渲染次数:${numRef.current}`);
  7. const onClick = () => {
  8. setA((old) => old + "a");
  9. setB((old) => old + "b");
  10. setC((old) => old + "c");
  11. numRef.current += 1;
  12. };
  13. return (
  14. <div className="App">
  15. <h1>Hello 开发者!</h1>
  16. <br />
  17. <div>
  18. <Button type="primary" onClick={onClick}>
  19. 点击按钮
  20. </Button>
  21. </div>
  22. </div>
  23. );
  24. }

例子-传送门
上面的例子中,按钮点击后更新了三个state,但重渲染只触发了一次。
图例:
image.png

不过,如果在异步、settimeout等函数中,却是另一番景象:

  1. export default function App() {
  2. const [a, setA] = useState("");
  3. const [b, setB] = useState("");
  4. const [c, setC] = useState("");
  5. console.log(`组件渲染`, a, b, c);
  6. // 异步代码中更新多个state
  7. // const onClick = async () => {
  8. // await 1;
  9. // setA((old) => old + "a");
  10. // setB((old) => old + "b");
  11. // setC((old) => old + "c");
  12. // };
  13. // setTimemout中更新多个state
  14. const onClick = () => {
  15. setTimeout(() => {
  16. console.log("setTimemout");
  17. setA((old) => old + "a");
  18. setB((old) => old + "b");
  19. setC((old) => old + "c");
  20. }, 1000);
  21. };
  22. return (
  23. <div className="App">
  24. <h1>Hello 开发者!</h1>
  25. <br />
  26. <div><Button type="primary" onClick={onClick}>点击按钮</Button></div>
  27. </div>
  28. );
  29. }

例子-传送门
点击按钮 触发了多次组件渲染,react并没有合并更新;
图例:
image.png
那么区别和产生的原因是什么?
上面两个例子的区别就在于异步、setTimeout等中使用,js引擎把更新操作放入到了EventLoop异步队列中执行了。React无法对后续操作主动介入合并,只是做了被动一一执行。

然而,在实际项目开发中,我们经常需要在async awaitPromise等异步回调中执行更新state的操作。在更新多个state且对多个state作为依赖项执行副作用操作的时候,就要比较小心了。

  1. useEffect(() => {
  2. // 请求接口数据
  3. fetch({a,b,c});
  4. },[a,b,c]);

上面的代码中,a、b、c变更都会触发请求,导致产生了两次多余请求,且接口处理快慢不一致,还易导致页面数据错乱。

上面只是一种常见场景,实际开发中对多个依赖项执行同一个副作用的场景很多,且更加复杂。没有处理好则很容易引起bug;
处理的方式有:

  1. 自行处理好更新多个state的先后关系,且副作用的执行增加限制条件;
  2. 使用useReducer收拢这些有依赖关系的state变更;

示例3:引用类型更新 浅比较问题

浅比较问题 setState ;在复杂数组对象变更时,引发的问题

  1. var a =[1,2,3]
  2. a.push(4)
  3. setA(a);

其实在react里useEffect\useCallback\useMemo等依赖项都是浅比较;这一点要注意了!对于复杂对象,如果只用到了某些属性,则依赖项完全可以只添加对应的属性:

  1. useEffect(()=>{
  2. ...
  3. },[info.name, info.age])

**

示例4:异步更新-竞态问题;

比如,页面中多场景变更 都会 触发同一异步请求去更新数据。如果第二次异步请求比第一次异步请求先返回,就会发生竞态的问题。页面渲染出不匹配的数据。
其中一种解决竞态问题的方式就是加入一个标识:
代码:

  1. useEffect(() => {
  2. let isCancel = false; // 取消异步请求处理 状态
  3. // 异步获取数据
  4. const qryData = async () => {
  5. try {
  6. const params = {a, b};
  7. const res = await fetch({ url: API_MESSAGE, payload: params });
  8. if (!isCancel) {
  9. // 存在竞态,则不更新数据。 否则更新数据
  10. curDispatch({ type: 'list-data', payload: list || [] });
  11. }
  12. } catch (err) {
  13. console.warn('接口处理失败,', err);
  14. }
  15. };
  16. qryData();
  17. return () => {
  18. isCancel = true;
  19. };
  20. }, [a, b]);

**

useEffect 依赖项问题;

关于useEffect、useCallback的依赖项的不当使用,是项目中很大部分的bug来源。

这里提几个准则:

  1. 关于依赖项不要对React撒谎;添加全部依赖项;

官方文档 也要求我们把effect中使用到的数据流都放入到依赖项中,包括state、props和组件内函数。

  1. 当添加的依赖项过多,比如十几个时,就得反思自己的状态拆分和组件拆分是否不合理了。

具体Hook API使用遇到的一些场景

useState

  1. 为了减少团队开发中其他开发者的的理解成本,useState变量放到函数组件 顶部;且最好增加注释;
  2. 尽量把state往上层组件提取,公共状态提取到公共父组件中;
  3. 任何数据,都要保证只有一个数据来源,而且避免直接复制它,也不要随意派生state。很多场景可以用传递props、useMemo解决。
  4. state拆分粒度:
    1. 当state状态过多,或state有关联变动时。可以根据数据状态的相关联性放到一个state对象里。
    2. 复杂状态的处理方式更推荐使用:useReducer;
      1. 页面里定义了一堆的state状态;
      2. 状态数据之间有联动变更的操作’比如: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. 减少子组件的非必要重渲染;
  1. // 子组件
  2. const Child = memo((props:any) => {
  3. console.log('子组件渲染...');
  4. return (<div>子组件渲染...</div>);
  5. });
  6. // 父组件
  7. const Parent = () => {
  8. const [info, setInfo] = useState({});
  9. const [count, setCount] = useState(0);
  10. const changeName = () => {
  11. console.log('更改信息了...');
  12. };
  13. return (
  14. <div className="App">
  15. <div>标识: {count}</div>
  16. <div onClick={() => { setCount(count => count + 1); }}>点击增加</div>
  17. <Child info={info} changeName={changeName}/>
  18. </div>
  19. );
  20. };
  21. export default Parent;

比如,在上方的例子中, count值变更,Parent组件重新渲染,却会触发Child子组件重新渲染。原因就是父组件中重新执行,重新生成新的changeName函数传入子组件,子组件props变更,触发重渲染。
在常规用法中,这样也没什么问题。但在性能要求高或子组件内重渲染代价过高等场景中,就得避免这样非必要的重渲染,解决方式就是修改父组件的 changeName 方法,用 useCallback 钩子函数包裹一层。

  1. 子组件把props中传入的函数作为effects等的依赖项,这时不加useCallback容易造成死循环等bug; ```javascript / bad case / let count = 0;

function Child({val, getData}) { useEffect(() => { getData(); }, [getData]); return

{val}
}

function Parent() { const [val, setVal] = useState(‘’); function getData() { setTimeout(() => { setVal(“new data “ + count); count++; }, 500); } return ; }

export default Parent;

  1. 在上方的代码中,Child子组件里useEffect根据getData获取数据。但实际情况是Parent父组件中每次val变更触发重渲染 getData都是重新生成,会造成死循环。<br />**解决方式**就是:使用useCallback包裹getData函数,达到缓存getData引用的目的。
  2. <a name="DajKR"></a>
  3. ### useRef
  4. 一般,useRef有两个使用场景:
  5. <a name="bHDT1"></a>
  6. #### 1. 指向组件dom元素
  7. a. 获取组件元素的属性值;<br />b. 用以操作目标指向domapi,如下方例子中的指向一个 input 元素,并在页面渲染后使 input 聚焦;
  8. ```javascript
  9. const Page = () => {
  10. const myRef = useRef(null);
  11. useEffect(() => {
  12. myRef.current.focus(); // 目标input聚焦
  13. });
  14. return (
  15. <div>
  16. <span>UseRef:</span>
  17. <input ref={myRef} type="text"/>
  18. </div>
  19. );
  20. };
  21. export default Page1;

2. 存放变量

可以保存任何可变值,且值不会进入依赖收集项内;
类似于class组件使用实例字段的方式,类似于this,在重渲染时,每次都会返回相同的引用;

  1. const Page = () => {
  2. const myRef = useRef(0);
  3. const [list, setList] = useState([])
  4. const onDelClick = () => {
  5. }
  6. const onAddClick = () => {
  7. }
  8. return (
  9. <div>
  10. <div onClick={()=> setCount(count+1)}>点击count</div>
  11. <div onClick={()=> setCount(count+1)}>点击count</div>
  12. <div onClick={()=> handleClick()}>查看</div>
  13. </div>
  14. );
  15. export default Page;

useMemo用法鉴赏

useMemo使用目的的不同,可以分为以下几个场景:

  1. 缓存复杂计算值,减少不必要的状态管理;

    1. export default Demo = ({info}) => {
    2. const money = useMemo(() => {
    3. // 计算 渲染值
    4. const res = calculateNum(info.num);
    5. return res;
    6. },[info.num]);
    7. return <div>价格是:{money}</div>
    8. }

    如上面的这段代码,money字段可以通过useMemo缓存,只有info.num变更才会重新计算,减少不必要计算的同时还可以避免维护不必要的派生state;

  2. 缓存部分jsx或组件,避免不必要的渲染;

    1. export default Demo = ({info}) => {
    2. const topEl = useMemo(() => (
    3. <div>
    4. <p>用户信息</p>
    5. {/* 渲染用户数据... */}
    6. </div>
    7. ),[info.user]);
    8. return <div>
    9. {topEl}
    10. {/* 渲染列表数据... */}
    11. </div>
    12. }

    上面的这段代码,一来可以通过在部分状态数据不变时,缓存对应的jsx;一来可以适当拆分复杂逻辑,使组件更简洁;
    当然处理逻辑复杂到一定程度,还是推荐抽离成独立组件,并通过memo包裹子组件;

错误用法:

你可以把 useMemo 作为性能优化的手段,但不要把它当成语义上的保证。

  1. 示例1:

    1. const Demo = () => {
    2. const [name, setName] = useState(undefined);
    3. const [copyNum, setCopyNum] = useState(0);
    4. // 控制展示值
    5. const topEl = useMemo(() => (
    6. <div>复制的值是:{name}</div>
    7. ), [copyNum]);
    8. return (
    9. <div className="page-demo">
    10. <Input value={name} onChange={(e) => setName(e.target.value)} placeholder="请输入名称" />
    11. <Button onClick={() => setCopyNum((old) => old+1)}>复制</Button>
    12. {topEl}
    13. </div>
    14. );
    15. };

    上面的这个简化版的例子中,本意是点击“复制”按钮的时候,复制输入框中当前的值。
    代码中,希望通过useMemo来控制展示结果,topEl中用到了name,却没有添加到依赖项中,只有点击按钮,copyNum变更,展示的内容name才会变更。
    看起来处理没问题,但却把useMemo用错了地方。即把useMemo用来控制渲染结果,对结果值进行了语义上的保证,而不是优化性能的目的。
    这会带来什么问题呢?造成状态值与渲染值的不匹配,造成混乱,还容易引起bug。

  2. 滥用useMemo;

    1. const Demo = () => {
    2. const [name, setName] = useState(undefined);
    3. const [copyNum, setCopyNum] = useState(0);
    4. // 控制展示值
    5. const topEl = useMemo(() => <div>复制的值是:{name}</div>, [name]);
    6. return useMemo(() => (
    7. <div className="page-demo">
    8. <Input value={name} onChange={(e) => setName(e.target.value)} placeholder="请输入名称" />
    9. <Button onClick={() => setCopyNum((old) => old + 1)}>复制</Button>
    10. {topEl}
    11. </div>
    12. ),[name, age, num,topEl,...]);
    13. };

    上面的例子,我在项目发现不少开发同学这么写, 本意是对整个组件的return jsx都进行缓存优化。但存在几个问题:
    1. 组件复杂之后,依赖项过多,每增加一个状态或useMemo、useCallback 都得手动加入到依赖项中。增加不必要的维护成本和出错概率。
    2. 组件执行重渲染,就是希望有相应的jsx,而不是对整个组件的返回做这种语义化的缓存,一来对于整个组件做状态变更缓存,相当于没做。对于需要优化缓存的部分,可以提取成每个独立的uesMemo部分;return只做组合。

若遇到组件改造,需加入组件提前返回,减少子组件渲染的情况,则直接就引起了bug;例:

  1. ...
  2. if (loading) {
  3. return <Loading />
  4. }
  5. return useMemo(() => (<div>...</div>), [name, ...]);

比较好的处理逻辑是 在的确需要优化,避免子组件不必要的重渲染的场景下,根据实际业务逻辑,拆分成多个useMemo缓存:

  1. // 根据页面功能模块拆分,处理成的不同逻辑单元;
  2. const topEl = useMemo(()=>(<div>...</div>),[topInfo]);
  3. const userEl = useMemo(()=>(<div>...</div>),[userInfo]);
  4. if (loading) {
  5. return <Loading />
  6. }
  7. return (<div>
  8. {topEl}
  9. ...
  10. {userEl}
  11. </div>);

当然 如果依赖项还是过多,则就要考虑使用useReducer收拢状态了。
逻辑复杂的组件还是要优先考虑 拆分成子组件;

useReducer的妙用

不要害怕使用useReducer

  1. 它没有你想的那么深奥,学会了可以解决不少实际问题;
  2. 在源码实现中,大量使用了reducer、dispatch的相关知识;本质上useState和useReducer的实现原理是差不多的。可以把useState理解为特殊的useReducer;与useReducer的区别是,为useState提供了一个预定义的reducer处理程序;

实际上useState返回的结果是一个reducer状态和一个action dispatcher;

使用举例: 待补充…

后语

前言要搭后语

概念理解的越透彻,使用就越顺畅。

我目前在做的就是在持续学习当中总结自己在实践React过程中的所见所学。

本篇文章主要是React Hooks相关知识,涉及的代码优劣在下一篇会专项探讨;