关于 React Hook 可能出现的使用误区总结

1,299 阅读10分钟

请记住react 的公式: UI = f(state) ,这很重要。

useState


🌰示例1:useState 拆分过细

const [firstName, setFirstName] = useState();
const [lastName, setLastName] = useState();
const [school, setSchool] = useState();
const [age, setAge] = useState();
const [address, setAddress] = useState();

const [weather, setWeather] = useState();
const [room, setRoom] = useState();

较好解决方式✌: 适当的合并 state

学会归类这些状态。

firstName, lastName 均是用户的信息,可以放在一个 useState 进行管理。

const [userInfo, setUserInfo] = useState({
  firstName,
  lastName,
  school,
  age,
  address
});

const [weather, setWeather] = useState();
const [room, setRoom] = useState();

注意🎈:useState 的 set 动作永远是 替换 值。(React class 中的 this.setState 是会进行 合并 的)

在进行变更用户的某个信息例如 年龄 的时候记得带上之前的值。

setUserInfo((prevInfo) => ({
  ...prevInfo,
  age: newAge
}))

🌰示例2:多个状态实则只是一个状态的变形

doneSource 、doingSource 是 source 转变的。

const SomeComponent = (props) => {
  
  const [source, setSource] = useState([
      {type: 'done', value: 1},
      {type: 'doing', value: 2},
  ])
  
  const [doneSource, setDoneSource] = useState([])
  const [doingSource, setDoingSource] = useState([])

  useEffect(() => {
    setDoingSource(source.filter(item => item.type === 'doing'))
    setDoneSource(source.filter(item => item.type === 'done'))
  }, [source])
  
  return (
    <div>
       ..... 
    </div>
  )
}

较好解决方式✌: 当一个状态可以被另外一个状态计算出来的话就不要去声明

const SomeComponent = (props) => {
  
  const [source, setSource] = useState([
      {type: 'done', value: 1},
      {type: 'doing', value: 2},
  ])
  
  // 这里的 useMemo 视实际情况添加,通常不是需要大量的计算 react 是不建议使用 useMemo
  const doneSource = useMemo(()=> source.filter(item => item.type === 'done'), [source]);
  const doingSource = useMemo(()=> source.filter(item => item.type === 'doing'), [source]);
  
  return (
    <div>
       ..... 
    </div>
  )
}

useRef


🌰示例1:多余的依赖

期望: 只有当 visible 变化时,弹出 Message 。

其中 text 、color 分别控制 弹窗的文案、背景颜色

function Demo(props) {
  const [visible, setVisible] = useState(false);
  const [text, setText] = useState('');
  const [color, setColor] = useState('red');

  useEffect(() => {
    Message(visible, text, color);
  }, [visible]);

  return (
    <div>
      <button
        onClick={() => setCount(visible =>!visible)}
      >
        click
      </button>
      <input value={text} onChange={e => setText(e.target.value)} />
      <input value={color} onChange={e => setColor(e.target.value)} />
    </div>
  )
}

如果你下载了 eslint-plugin-react-hooks插件的话,你会发现这行代码出现警告。

于是你在 effect deps 增加了 textcolor 依赖。

于是出现了一个问题,当 textcolor 发生变化的也会上传数据, 这并不符合我们的目标。

较好解决方式✌:善用 useRef

textcolor 改变的时候不需要重新更新视图的时候,尝试使用 useRef 去替代。

使用 useRef 去替换useState

function Demo(props) {
  const [visible, setVisible] = useState(false);
  const textRef = useRef('');
  const colorRef = useRef('red');

  useEffect(() => {
    // 注意这里的 Message 内部接收的时候也要做处理
    // 不能直接传 textRef.current, 这样可能会导致 Message 无法接收到最新的值
    Message(visible, textRef, colorRef);
  }, [visible]);

  return (
    <div>
      <button
        onClick={() => setVisible(preVisible => !preVisible)}
      >
        click
      </button>
      <input value={text} onChange={e => { textRef.current = e.target.value }} />
      <input value={color} onChange={e => { colorRef.current = e.target.value } />
    </div>
  )
}

思考💡: 如果 text 、color 值需要在视图上进行渲染,如何进行设计? - useReducer。

🌰示例2:缺少依赖导致的闭包问题

永远不要欺骗 hook

期望: 当进入页面 3s 后,输出当前最新的 count

function Demo() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setTimeout(() => {
      console.log(count)
    }, 3000);
    return () => {
      clearTimeout(timer);
    }
  }, [])

  return (
    <button
      onClick={() => setCount(c => c + 1)}
    >
      click
    </button>
  )
}

同样,当我们拥有 eslint-plugin-react-hooks插件的时候还是会报缺少 count 的错误, 且该代码在 3s 内多次点击按钮, 还是会输出 0。

此时我们想到将 count 加入到依赖项。

  useEffect(() => {
    const timer = setTimeout(() => {
      console.log(count)
    }, 3000);
    return () => {
      clearTimeout(timer);
    }
  }, [count])

但是这样又会陷入一个怪圈,当我们在点击的时候会重新调用 useEffect 中的方法。 这样并没有达到我们的需求。

较好解决方式✌:

解法一:在有延迟调用场景时,可以通过 ref 来解决闭包问题

function Demo() {
  const countRef = useRef(0);

  useEffect(() => {
    const timer = setTimeout(() => {
      console.log(countRef.current)
    }, 3000);
    return () => {
      clearTimeout(timer);
    }
  }, [])

  return (
    <button
      onClick={() => { countRef.current++ }}
    >
      click
    </button>
  )
}

解法二: 可能我们需要一个自定义的 hook useEvent。

假设 count 值需要在进行视图展示,换句话说就是当 count 改变的时候会改变视图。

可能我们需要一个自定义的 hook useEvent

具体的内容,你可以查看 Dan 在社区发布的一篇文章 useEvent 🔗

useEvent 实现方式 :

import React, { useRef, useLayoutEffect, useCallback } from 'react';

function useEvent(handler) {
  const handlerRef = useRef();

  // 在 render 之前执行
  useLayoutEffect(() => {
    handlerRef.current = handler;
  });

  return useCallback((...args) => {
    const fn = handlerRef.current;
    return fn(...args);
  }, []); // 因为 ref 的地址不会发生变化,可以在依赖项中进行忽略(同理 setState 也是)
}
export default useEvent;

最终代码:

import React, { useState, useEffect } from 'react';

import useEvent from '@/hooks/useEvent';

function Demo() {
    const [count, setCount] = useState(0)

    const consoleCount = useEvent(() => {
        console.log(count)
    })

    useEffect(() => {
      const timer = setTimeout(() => {
          consoleCount();
      }, 3000);
      return () => {
        clearTimeout(timer);
      }
    }, [])
  
    return (
      <>
        <div>当前数量:{ count }</div>
        <button
            onClick={() => { setCount(pre => pre+1) }}
        >
            click
        </button>
      </>
    )
  }

export default Demo;

被该 useEvent 包裹的函数,拿到外部的 props 或者 state 永远是最新的值。

useEffect


请特别注意以下两点📢:

  1. useEffect在开发调试阶段会运行两次。 React 18 最大的特性之一就是可以支持稳定的并发渲染,在实际开发中我们可以加上strickMode来开启严格模式来支持稳定的并发渲染,该模式下为了能够暴露出一些特定情况的 bug, react 会在开发模式下调用两次 useEffect
  1. 请不要将 useEffect 当做 watcher监听的方式来使用

如果想跟上 react 的技术更新这些真的很重要,提前去注意总是好的,也是为了以后更好的迭代项目做准备。

哪怕是现在的项目并没有开启该模式。

🌰示例1:props 改变的时候 重置 state

期望: 当 userId变化的时候,将 ProfilePage组件的评论状态(即组件全部状态)清空

export default function ProfilePage({ userId }) {
  const [comment, setComment] = useState('');

  useEffect(() => {
    setComment('');
  }, [userId]);
  return <div>{comment}</div>
}

当前组件如果需要展现正确的值的时候,中间会更新一次 dom然后去运行 useEffect, 由于改变状态并再次进行渲染及其子组件,无疑增加了额外的一次渲染。

较好解决方式 ✌:

export default function App({ userId }) {
  return (
    <div>
      <ProfilePage key={userId} userId={userId} />
    </div>
  )
};
      
function ProfilePage({ userId }) {
  const [comment, setComment] = useState('');
  return <div>{comment}</div>
}      
      

userIdProfilePage组件 key 的标识,当 userId发生变化的时候,由于组件ProfilePagekey 不同 react 则会重新render该组件的, 状态不在复用而是重建, 你不用担心这样会新建 dom, react fiber会进行比较,是否选择复用缓存。

🌰示例2:state 依赖于 props

期望: 当 items变换的 只重置selection的状态

function List({ items }) {
  const [selection, setSelection] = useState(null);
  const [otherState, setOtherState] = useState(xxx);
  

  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
}

咋一看没有什么问题,让我们仔细看一下这段代码是如何运行的:

  1. 第一次render:当 items变化的时候: 整个组件重新运行,此时selection的值为旧值, 当组件更新 dom之后, 运行useEffect的函数,由于运行了 set函数,组件需要重新 render .
  2. 第二次 render, 此时的 selection内部的值是最新的值。

❓ 可是我们只是变化了一次 itemslist组件居然渲染了两次页面, 显然跟我们想的不一样。

较好解决方式 1✌:

function List({ items }) {
  const [selection, setSelection] = useState(null);
  const [otherState, setOtherState] = useState(xxx);
  
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }
  // ...
}

在渲染期间就改变selection

prevItems总是记录上一次的值, 判断是否发生了变化, 如果发生变化则重新更新 selection,并储存当前items, 保证在下次渲染的时候使用的是上一次的值。 由于在第一次 render,更新到真实dom树上的时候, selection值已经是最新的了, 整个组件则渲染完毕。

当然在这里你可能会想到使用 React.memo 可以自己去尝试下。

较好解决方式 2✌:

当然,在例子中我们也发现,其实 selection更多是取决于items,他严格来说是依赖于 props 的一个变量,大可不必作为一个新的状态存储在 List组件中。

function List({ items }) {
  const [otherState, setOtherState] = useState(xxx);
  const selection = items.find(item => item.id === selectedId) ?? null;
  // ...
}

React 更推荐这种方式去处理:

不管你怎么做,根据 props 或其他状态调整状态会使你的数据流更难理解和调试。当你检查代码的时候应考虑是否可以 重置所有状态 或者 在渲染期间计算所有内容

---- react 新文档

🌰示例3:正确的请求方式

期望: 初始化页面的时候

function App() {
  useEffect(() => {
    loadDataFromLocalStorage();
    checkAuthToken();
  }, []);
  // ...
}

但是可能在开发模式下会渲染两次 ,尝试做一次判断。

question: 为什么开发模式下会渲染两次?

简单的来说,大部分人在开发的过程中并不会注意到去清理 Effect,提前在开发阶段暴露问题给开发者。详见开发中初次渲染调用两次 useEffect 内函数。

🎈例如我在 useEffect去注册一个事件,但是我并没有 return 一个 清理该监听的事件的函数; 或者是 使用一些弹窗,而这个组件可能会为了防止开发人员多次调用而创建多个 portals,所以只允许实例化一次。

但这些可能会造成:

  1. 调用弹窗关闭后未清除弹窗组件导致二次调用报错。

2.当该绑定事件到达一定量,页面由于绑定的事件过多会对用户浏览流畅性产生一定影响。

较好解决方式 ✌:

function App() {
  useEffect(() => {
    if(!isInit) {
      loadDataFromLocalStorage();
      checkAuthToken();
    }
  }, []);
  // ...
}

🌰示例4:解决竞态问题

function User({ query }) {
  const [data, setData] = useState(null);

  useEffect(() => {
    const res = await fetch(`https://xxx?${query}/`);
    setData(res.json());
  }, [query]);

  if (data) {
    return <div>{data.name}</div>;
  } 
  return null;
}

这里可能会有一个问题,当 query变化足够快的时候,可能会同时发出两个请求,而这两个请求可能返回时间的先后顺序与你预想不一致。举个例子:

假设我们希望查询 https://xxxx?id=123, 即 query 我们希望输入的是 123。但是由于 inputonChange函数在 input每次改变的时候都会改变 query 值, 输入间隔短倒一定时间的时候,输入12的时候与输入 123的请求同时发送。但是可能 123请求结果先返回, 后面12的请求结果后返回,则可能会造成 请求12的结果覆盖了我们希望得到的123的请求结果。

question: 什么是 竞态问题?

query变化足够快, 就会发起不同的请求,而展示哪个最终取决于最终返回的那个结果,这可能并不符合你的预期显示内容。

较好解决方式 ✌

加一个锁

function User({ query }) {
  const [page, setPage] = useState(1); 
  const data = useData(`/api/search?${query}`);

  // ...
}

function useData(url) {
  const [data, setData] = useState(null);
  useEffect(() => {
    let ignore = false;
    fetch(url)
      .then(res => {
        if (!ignore) {
          setData(res?.data || {});
        }
      });
    return () => {
      ignore = true;
    };
  }, [url]);
  return result;
}

我们简单的梳理一下,还是queryid 为 123的例子。手速过快同时发送12, 123的请求,在每一次请求中,总是会执行上一次副作用的 cleanup 函数, 当发起123的请求的时候,请求 12的函数由于运行了cleanup函数, ignore变量已经被赋值为 true, 无论如何都没办法进入 setData 的操作, 这就解决了竞态竞争的问题。

或许你可以尝试使用一些市面上比较优秀的库: ahooksreact-usereact-QueryuseSWR。内部已经做了类似的判断。

useMemo


在遇到一些计算量大的时候我们总是会想到使用 useMemo的 hook,对计算内容进行缓存。

在进行函数优化的时候,也会想到使用 React.memo 中添加第二个函数来进行比较是否需要优化该函数。

但是在使用这些 hook 之前,可能你需要先考虑一下是否可以使用以下两种解决方式:

示例1 :向下移动 state:

// 子组件 
// 模拟组件重新渲染需要大量的计算
function ExpensiveTree() {
  const nowTime = new Date()
  while (new Date() - nowTime < 500 ) {
    
  }
  return <div> slower comp</div>
}
// 父组件
export default function Demo() {
  const [color, setColor] = useState('#eee');
  const handleChange = (e) => {
    setColor(e.target.value)
  }
  return (
    <div>
      背景颜色: <input value={color} onChange={handleChange}/>
      <ExpensiveTree />
    </div>
  );
}

可以看到,每当我在 input框输入的时候都会重新渲染 ExpensiveTree,可能我们第一眼的直觉是不是只要将 ExpensiveTree组件包一层 useMemo不就好了吗,但是或许可以用另外一种的方式.

较好解决方式✌:

function ExpensiveTree() {
  const nowTime = new Date()
  while (new Date() - nowTime < 500 ) {
    
  }
  return <div> slower comp</div>
}

// 看这里👉:  将 input 中所需内容下移,变成一个组件
function Form() {
    const [color, setColor] = useState('#eee');
    const handleChange = (e) => {
      setColor(e.target.value)
    }
    return (<div>
      背景颜色: <input value={color} onChange={handleChange}/>
    </div>)
}

export default function Demo() {

  return (
    <div>
      <Form />
      <ExpensiveTree />
    </div>
  );
}

将原先会改变状态一部分内容给提取到一个组件中,与 ExpensiveTree形成并列关系。也就是将 state 状态下移了。 此时更新 state 的时候,只会重新render该子组件内部的内容。

示例2 :提升组件

🤔 但是如果父组件也依赖 input值呢, 即 state 上升到上层组件?

我们改一下示例:

// 子组件 
// 模拟组件重新渲染需要大量的计算
function ExpensiveTree() {
  const nowTime = new Date()
  while (new Date() - nowTime < 500 ) {
    
  }
  return <div> slower comp</div>
}
// 父组件
export default function Demo() {
  const [color, setColor] = useState('#eee');
  const handleChange = (e) => {
    setColor(e.target.value)
  }
  return (
 👉 <div style={{ backgroundColor: color }} >
      背景颜色: <input value={color} onChange={handleChange}/>
      <ExpensiveTree />
    </div>
  );
}

此时上层 dom div的背景颜色需要 input的值来控制此时没办法使用刚才的方法了。但是真的只能用 useMemo了吗?

较好解决方式✌:

function ColorPicker({ children }) {
  let [color, setColor] = useState("red");
  return (
    <div style={{ color }}>
      背景颜色:<input value={color} onChange={(e) => setColor(e.target.value)} />
      {children}
    </div>
  );
}

export default function Demo() {
  return (
    <ColorPicker>
      <ExpensiveTree />
    </ColorPicker>
  );
}

demo组件根据是否关心 color状态来分为两个组件树 ColorPickerExpensiveTree。 而 ExpensiveTree通过children属性传递到 ColorPicker

这样的话从组件结构来看 ExpensiveTree 是否改变不取决于 ColorPicker 组件是否改变, 在改变 color 状态的时候,ColorPicker重新render的时候并不会导致内部 children重新 render,而是从缓存中复用(请注意,这里的缓存是 react 的双缓存树)。

useReducer(待续。。)

参考文档:

zhuanlan.zhihu.com/p/450513902 (在阅读来源文章的时候请保持 UI = f(state) 的观念,文章内容并不一定是正确的)

beta.reactjs.org/learn/you-m… 《或许你不需要useEffect》

mp.weixin.qq.com/s/0RfqiObXz…《在React18中请求数据的正确姿势》

beta.reactjs.org/learn/synch…《如何在开发中处理两次 effect 调用》

github.com/reactjs/rfc… 《useEvent》

overreacted.io/zh-hans/bef…《在你使用 memo 之前 》