React必知必会(三)-React Hooks优劣代码鉴赏

990 阅读13分钟

前言

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;

react批量更新机制

示例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,但重渲染只触发了一次。
图例:
image.png

不过,如果在异步、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并没有合并更新;
图例:
image.png


那么区别和产生的原因是什么?
上面两个例子的区别就在于异步、setTimeout等中使用,js引擎把更新操作放入到了EventLoop异步队列中执行了。React无法对后续操作主动介入合并,只是做了被动一一执行。

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

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

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

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

  1. 自行处理好更新多个state的先后关系,且副作用的执行增加限制条件;
  2. 使用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来源。

这里提几个准则:

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

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

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

具体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. 减少子组件的非必要重渲染;
// 子组件
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 钩子函数包裹一层。

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

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

function Parent() { 
  const [val, setVal] = useState('');
  function getData() {
    setTimeout(() => {
      setVal("new data " + count); 
      count++;
    }, 500); 
  } 
  return <Child val={val} getData={getData} />; 
} 

export default Parent;

在上方的代码中,Child子组件里useEffect根据getData获取数据。但实际情况是Parent父组件中每次val变更触发重渲染 getData都是重新生成,会造成死循环。
解决方式就是:使用useCallback包裹getData函数,达到缓存getData引用的目的。

useRef

一般,useRef有两个使用场景:

1. 指向组件dom元素

a. 获取组件元素的属性值;
b. 用以操作目标指向dom的api,如下方例子中的指向一个 input 元素,并在页面渲染后使 input 聚焦;

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使用目的的不同,可以分为以下几个场景:

  1. 缓存复杂计算值,减少不必要的状态管理;
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;

  1. 缓存部分jsx或组件,避免不必要的渲染;
export default Demo = ({info}) => {
  const topEl = useMemo(() => (
    <div>
    	<p>用户信息</p>
    	{/* 渲染用户数据... */}
    </div>
  ),[info.user]);
  
  return <div>
    {topEl}
  	{/* 渲染列表数据... */}
    </div>
}

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

错误用法:

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

  1. 示例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。

  1. 滥用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

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

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

使用举例: 待补充...

后语

前言要搭后语  


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

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

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