写点什么

React-Hook 最佳实践

作者:xiaofeng
  • 2022-10-19
    浙江
  • 本文字数:13527 字

    阅读完需:约 1 分钟

React Hook 新出现背景

类组件的问题

  • 复用组件状态难,高阶组件+渲染属性 providers customers,等一堆工具都是为了解决这个问题,但是造成了很严重的理解成本和组件嵌套地狱

  • 生命周期带来的负面影响,逻辑拆分严重

  • This 的指向问题

函数组件的局限

  • 之前函数组件没有 state 和 生命周期,导致使用场景有限

React Hook

HooksReact 16.8 新增的特性,它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性,无需转化成类组件

Hook 的使用和实践

useStateHook 的闭包机制

// hook 组件function Counter() {  const [count, setCount] = useState(0);  const log = () => {    setCount(count + 1);    setTimeout(() => {      console.log(count);    }, 3000);  };  return (    <div>      <p>You clicked {count} times</p>      <button onClick={log}>Click me</button>       </div>  );}
// 等效的类组件class Counter extends Component { state = { count: 0 }; log = () => { this.setState({ count: this.state.count + 1, }); setTimeout(() => { console.log(this.state.count); }, 3000); }; render() { return ( <div> <p>You clicked {this.state.count} times</p> <button onClick={this.log}>Click me</button> </div> ); }}
复制代码


快速点击下的情况下,想想 Hook 组件和函数式组件控制台打印出来的是什么?


  • 类组件打印出来的是 3 3 3

  • Class 组件的 state 是不可变的,通过 setState 返回一个新的引用,this.state 指向一个新的引用

  • setTimeout 执行的时候,通过 this 获取最新的 state 引用,所以这个输出都是 3

  • 函数组件打印的结果是 0 1 2

  • 函数组件闭包机制,函数组件每一次渲染都有独立的 propsstate

  • 每一次渲染都有独立的事件处理函数

  • 每一次渲染的状态不会受到后面事件处理的影响

函数组件渲染拆解

既然每次渲染都是一个独立的闭包,可以尝试代码拆解函数式组件的渲染过程


// 第一次点击function Counter() {  const [0, setCount] = useState(0);    const log = () => {    setCount(0 + 1);    // 只能获取这次点击按钮的 state    setTimeout(() => {      console.log(0);    }, 3000);  };}// 第二次点击function Counter() {  const [1, setCount] = useState(0);    const log = () => {   setCount(1 + 1);    setTimeout(() => {      console.log(1);    }, 3000);  };}// 第三次点击function Counter() {  const [2, setCount] = useState(0);   const log = () => {    setCount(2 + 1);    setTimeout(() => {      console.log(2);    }, 3000);  };}
复制代码


  • 三次点击,共 4 次渲染,count0 变为 3

  • 页面第一次渲染,页面看到的 count = 0

  • 第一次点击,事件处理器获取的 count = 0count 变成 1, 第二次渲染,渲染后页面看到 count = 1,对应上述代码第一次点击

  • 第二次点击,事件处理器获取的 count = 1count 变成 2, 第三次渲染,渲染后页面看到 count = 2,对应上述代码第二次点击

  • 第三次点击,事件处理器获取的 count = 2count 变成 3, 第四次渲染,渲染后页面看到 count = 3,对应上述代码第三次点击

让函数式组件也可以输出 3 3 3

有种比较简单并且能解决问题的方案,借用 useRef


  • useRef 返回一个可变的 ref 对象,其  current  属性被初始化为传入的参数(initialValue)

  • useRef 返回的 ref 对象在组件的整个生命周期内保持不变,也就是说每次重新渲染函数组件时,返回的 ref 对象都是同一个

  • useRef 可以类比成类组件实例化后的 this,在组件没有销毁的返回的引用都是同一个


function Counter() {  const count = useRef(0);  const log = () => {    count.current++;    setTimeout(() => {      console.log(count.current);    }, 3000);  };  return (    <div>      <p>You clicked {count.current} times</p>      <button onClick={log}>Click me</button>        </div>  );}
复制代码


  • 这样修改一下,控制台输出的确实是 3 3 3

  • ? 既然 Ref 对象整个生命周期都不变,修改 current 属性也只是修改属性,那除了打印,这里的 You clicked 0 times ,点击三次,会变成 3 么?

  • 显然不能,这个组件没有任何的属性和状态改变,会重新渲染才怪,所以这里虽然点击了 3 次,但是不会像 useState 一样,渲染 4 次,这里只会渲染 1 次,然后看到的都是 You clicked 0 times

  • 修复一个问题把另外一个更大的问题引进来,这很程序员。。。

useEffect

通过 useRef 虽然能解决打印的问题,但是页面渲染是不对的,这里还是使用 useState 的方案,配合 useEffect 可以实现我们想要的效果


function useEffect(effect: EffectCallback, deps?: DependencyList): void;
复制代码


  • 看下 useEffect 的签名,effect 是函数类型,并且必填, 还有第二个可选参数,类型是只读数组

  • useEffect  是处理副作用的,其执行时机在   每次 Render 渲染完毕后,换句话说就是每次渲染都会执行,在真实 DOM 操作完毕后。


配合这个 hook, 如果每次 state 改变后渲染完之后,把 ref 里面的值更新,然后控制台打印 ref 的值,参考 React 实战视频讲解:进入学习


function Counter() {  const [count, setCount] = useState(0);  const currentCount = useRef(count);  useEffect(() => {    currentCount.current = count;  });  const log = () => {    setCount(count + 1);    setTimeout(() => {      console.log(currentCount.current);    }, 3000);  };  return (    <div>      <p>You clicked {count} times</p>      <button onClick={log}>Click me</button>         </div>  );}
复制代码


这样子写可以符合我们的预期效果,页面展示从 0 1 2 3, 然后控制台输出 3 3 3,然后我们拆解下渲染过程。


  • 三次点击,共 4 次渲染,count0 变为 3

  • 页面初始化渲染,count = 0currentCount.current = 0, 页面显示 0, 渲染完成,触发 useEffectcurrentCount.current = 0

  • 第一次点击,count = 0, 渲染完成后,count = 1, 页面显示 1,触发 useEffectcurrentCount.current = 1

  • 第二次点击,count = 1, 渲染完成后,count = 2, 页面显示 2,触发 useEffectcurrentCount.current = 2

  • 第三次点击,count = 2, 渲染完成后,count = 3, 页面显示 3,触发 useEffectcurrentCount.current = 3

  • 三次点击完成,currentCount.current = 3,第四次渲染,页面看到 count = 3setTimeout 中调用的是 currentCount 这个对象,输出都是 3

useEffect 的函数返回值

type EffectCallback = () => void | (() => void | undefined);
复制代码


useEffect 的回调函数可以返回空,也可以返回一个函数,如果返回一个函数的话,在 effect 执行回调函数的时候,会先执行上一次 effect 回调函数返回的函数


useEffect(() => {  console.log('after render');  return () => {    console.log('last time effect return');  };});
复制代码


这个 useEffect ,每次渲染完之后,控制台会先输出 last time effect return,然后再输出 after render

useEffect 和 类组件生命周期

之前提到,useEffct 有两个参数,第二参数是个可选参数,是 effect 的依赖列表, React 根据这些列表的值是否有改变,决定渲染完之后,是否执行这个副作用的回调


如果不传这个参数,React 会认为这个 effect 每次渲染然之后都要执行,等同于 componentDidUpdate 这个生命周期无约束执行


useEffect(() => {  currentCount.current = count;});componentDidUpdate() {  currentCount.current = this.state.count;}
复制代码


如果这个参数是空数组,React 会认为组件内任何状态和属性改变,都不会触发这个 effect,相当于这个 effect 是仅仅在组件渲染完之后,执行一次,后面组件任何更新都不会触发这个 effect,等同 componentDidMount


useEffect(() => {  currentCount.current = count;}, []);componentDidMount() {  currentCount.current = this.state.count;}
复制代码


如果配合 useEffect 回调函数的返回函数,可以实现类似 componentWillUnmount 的效果,因为如果是空数组的话,组件任何更新都不会触发 effect,所以回调函数的返回函数只能在组件销毁的时候执行


useEffect(() => {  return () => {    console.log('will trigger ar willUnmount')  }}, []);componentWillUnmount() {  console.log('will trigger ar willUnmount')}
复制代码


如果依赖列表里面有值,则类似componentDidMount有条件约束更新,只有当上一次的状态和这次的不一样,才执行


useEffect(() => {  currentCount.current = count;}, [count]);componentDidUpdate(prevProps, prevState) {  if (prevState.count !== this.state.count) {    currentCount.current = this.state.count;  }}
复制代码

useEffect 和 闭包问题

假设组件需要在初始化的时候,定义一个定时器,让 count 自增,自然而然的可以写出以下的代码


// 初始化的 count = 0useEffect(() => {  const id = setInterval(() => {    setCount(count + 1);  }, 1000);}, []);componentDidMount() {  setInterval(() => {    this.setState({ count: this.state.count + 1 });  }, 1000);}
复制代码


但是实际运行的时候,类组件展示是对的,函数组件从 0 递增到 1 之后,页面渲染就再也不变了


  • 之前有提过,类组件因为有 this 这个引用,很容易通过 state 拿到最新的值

  • 函数组件每次渲染都是独立的闭包,这里因为写的依赖值是 [],所以只有首次渲染后,才会这行这个 effect,首次渲染后, count 就是 0,所以 setCount(count + 1) 每次都是执行 setCount(0 + 1),所以定时器工作是正常的,不过取的值有问题。

闭包问题的切入点和发生场景

闭包问题,大多发生在,有些回调函数执行,依赖到组件的某些状态,但是这些状态并没有写到 useEffect 的依赖列表里面。导致执行回调函数的时候,拿到组件的状态不是最新的。


主要的场景有:


  • 定时器

  • 事件监听的回调

  • 各种 Observer 的回调


这些场景,通常只要在组件初始化渲染完之后,定义一次回调函数就好,但是如果回调函数依赖到组件的转态或者属性,这时候就要小心,闭包问题


function Router() {  const [state, setState] = useState<string>('');  useEffect(() => {    window.addEventListener<'hashchange'>(      'hashchange',      () => {        // 监听 hash 变化,这里依赖到 state      },      false    );  }, []);}
复制代码


例如这里的写法,在组件渲染完监听 hashchange ,回调函数是拿不到后续更新的 state 的,只能能到初始化时候的空字符串。

尝试解决闭包问题-监听state变化

既然回调函数要每次都拿到最新的 state,可以监听 state 的变化,state 变化的时候,重新定义事件监听器,改写一下


function Router() {  const [state, setState] = useState<string>('');  useEffect(() => {    window.addEventListener(      'hashchange',      () => {        // 监听 hash 变化,这里依赖到 state      },      false    );  }, [state]);}
复制代码


以上代码能用,但是 state 每次改变,就会重新定义一个 hashchange 回调函数,但是上一次的 hashchange 的事件监听器并没有清除,代码能跑,但是内存泄漏也太严重了,可以配合 useEffect 回调函数返回的函数配合清掉上一次的事件监听器


function Router() {  const [state, setState] = useState<string>('');  useEffect(() => {    const callback = () => {};    window.addEventListener('hashchange', callback, false);    return () => window.removeEventListener('hashchange', callback, false);  }, [state]);}
复制代码


这样内存泄漏的问题被解决了,但是这种事情监听,正常来说设置一次就好,没必要重新定义,还有别的更好的方法么?

尝试解决闭包问题 - setState 另外一种更新组件状态的方式

useState 返回的更新状态的函数,除了可以传一个值,还可以传一个回调函数,回调函数带一个参数,这个参数是最新的 state,像这样的话,之前那个定时器的例子,可以修改成这样。


function Counter() {  const [count, setCount] = useState(0);  useEffect(() => {    const id = setInterval(() => {      // setCount(count + 1)      setCount((c) => c + 1);    }, 1000);    return () => clearInterval(id);  }, []);  return <h1>{count}</h1>;}
复制代码


这里我们改了一行代码,setCount(count + 1) 改成了 setCount((c) => c + 1),这样修改之后,其实定时器回调已经没有依赖到 count 这个值了,由 setCount 里面的回调函数,返回最新的 count 的值,就是setCount((c) => c + 1),里面的 c.


同样的,对于事件监听器里面,我们也可以通过这个方式去获取最新的 state,但是这里有几个问题


  • 这个回调函数,其实也只要获取最新的 state,所以在调用 setState 的时候,拿到最新的值的同时,记得把 setState 的值,设置成和当前同一个,如果没有返回,那调用 setState 之后, state 的值会变成 undefined

  • setState 返回一个同样的值,会不会导致组件和它的子组件重新渲染?找了下文档说明是这样的:调用 State Hook 的更新函数并传入当前的 state 时,React 将跳过子组件的渲染及 effect 的执行。需要注意的是,React 可能仍需要在跳过渲染前渲染该组件。不过由于 React 不会对组件树的“深层”节点进行不必要的渲染,所以大可不必担心

  • 看起来可行的,做一下简单的修改其实可以改成这样


function Router() {  const [state, setState] = useState<string>('');  useEffect(() => {    const callback = () => {      let latestState = ‘’;      setState((stateCallback) => {        // stateCallback 是最新的 state        latestState = stateCallback;        // 记得把 stateCallback 返回去,不然 state 会被改成 undefined        return stateCallback;      });      // latestState 已经被赋值为最新的 state 了    };    window.addEventListener<'hashchange'>('hashchange', callback, false);  }, [])}
复制代码


这样基本就没问题了,做到了只定义了一次回调,然后也可以获取最新的 state,一举两得,但是还是有问题的


  • setState 回调函数如果不写 return stateCallback; 这段代码,会导致 state 莫名其妙被设置成 undefined ,而且非常不好发现,维护性太差

  • setState 是用来改变组件的 state 的,不是让你这样用的的,虽然这样用完全没问题。但是可维护性太差了,如果你的代码被接手,别人就会疑惑这里为什么要这么写,无注释和变量命名太糟糕的情况下,代码可以维护性基本为 0

  • 设置一个同样的 state,虽然不会导致子组件重新渲染,但是本组件还是有可能重新渲染的,按官网的说法


这个方案不完美。思路再发散一下?执行回调函数的时候,需要获取到最新的 state,能不能用一个不变的值缓存 state ? 等等?? 不变的值???

解决闭包问题最佳实践-useStateuseRef

useRef的返回是在整个组件生命周期都是不变的一个对象,可以借助 useRef 来获得最新的 state。例如这个例子可以改成这样:


function Router() {  const [state, setState] = useState<string>('');  const stateRef = useRef<string>(state);  // 这样,可以把 stateRef 和最新的 state 绑定起来  stateRef.current = state;  // 或者这样,可以把 stateRef 和最新的 state 绑定起来  useEffect(() => {    stateRef.current = state;  }, [state]);  useEffect(() => {    const callback = () => {      const latestState = stateRef.current;    };    window.addEventListener<'hashchange'>('hashchange', callback, false);  }, []);}
复制代码


stateRef.current 上面两种写法,都可以获得最新的 count,回调函数里面里面直接读取 stateRef.current 的值,可以拿到最新的 state 闭包问题的最优解,节本就是这样了。

useRefuseState 的最佳实践

  • useStateuseRef 仔细想想和和类组件的什么属相很相似?是不是和 this.statethis 的属性很像

  • 在类组件中,如果是不参渲染的属性,直接挂 this 上就好了,如果需要参与渲染的属性,挂在 this.state

  • 同样的,在 Hook 中,useRefuseState 可以实现类似效果


例如以下的例子


// 函数组件const Child = React.memo(() => {  // count 参与页面渲染  const [count, setCount] = useState(0);  // userInfo 不参与渲染  const userInfo = useRef(null);});// 类组件class Child extends React.PureComponent {  constructor(props) {    super(props);    // 不参与渲染    this.userInfo = null;    // 参与渲染的属性    this.state = {      count: 0,    };  }}
复制代码

再看看 useEffect 回调函数的返回值

type EffectCallback = () => (void | (() => void | undefined));return void | (() => void | undefined)
复制代码


确定是没有返回或者返回一个函数,所以下面这种写法是有问题的,虽然也没有明显标明返回体,就是没有返回一样,但是这个回调函数是异步函数,异步返回默认返回一个 Promise 对象,所以这种写法是不提倡的


const [data, setData] = useState({ hits: [] });  useEffect(async () => {    const result = await axios(      'url‘,    );    setData(result.data);}, []);
复制代码


为了规避这个问题,可以修改一下写法


useEffect(() => {  const fetchData = async () => {    const result = await axios('url');    setData(result.data);  };  fetchData();}, []);
复制代码

useCallback

把函数写进里面没什么问题,官方也推荐,但是万一我的副作用里面需要处理多个函数或者一个超长的函数的话,一个是不美观,一个是太难维护


这个适用可以利用 useCallback 把函数抽离出去,useCallback 返回一个记忆化的函数,当且仅当依赖列表有任何属性改变的时候,它才会返回一个新的函数,所以这个特性比较适合传给子组件的回调函数


function Counter() {  const [count, setCount] = useState(0);  const getFetchUrl = useCallback(() => {    return 'https://v?query=' + count;  }, [count]);  useEffect(() => {    getFetchUrl();  }, [getFetchUrl]);  return <h1>{count}</h1>;}
复制代码


这里如果 count 改变的时候,getFetchUrl的值也会改变,从而导致 useEffect 也触发

React.memo

React.memo() 返回一个记忆化的值,如果 React 内部会判定,如果重新渲染 props` 不相等,就会重新渲染,如果没有改变,就不会触发组件渲染


这个特性比较有用,因为如果父组件重新渲染的时候,子组件就会重新渲染,使用这个特性可以减少不必要的子组件重新渲染


const Child = memo((props) => {  useEffect(() => {  }, [])  return (    // ...  )}, (prevProps, nextProps) => {  // 判定相等的逻辑  // 假如某些属性的改变不需要重新渲染  // 可以编写这个函数})

复制代码

React.useCallbackReact.memo

  • 为什么讲 useCallback 要把 memo 拎出来讲,想一下 useCallback 的作用,返回一个缓存的函数,在函数组件里面,每次渲染都会执行一次组件函数,组件函数每次执行,在组件内部的函数都会重新定义,这样的话,父组件传给子组件的回调函数每次渲染都会变

  • 再从 memo 的角度去看,父组件每次渲染,子函数组件如果不加 memo 的话,就算是子组件无任何依赖,属性都不变的情况下,子组件也会重新渲染

  • 如果在父组件单独加为子组件的回调函数添加 useCallback,这样可以避免回调函数重新定义,但是子组件如果不用 memo 包裹,就算任何子组件属性没改变,还是会导致子组件重新渲染;

  • 同样的,如果子组件单独用 memo 包裹,父组件每次渲染,重新定义回调函数,还是会导致重新

  • 所以,memouseCallback 必须都用上,不然是没用的,不仅达不到优化的效果,而且会加重 React 比较的负担。要不就别用,要不就都用上。

React.useCallbackReact.memo 最佳实践

父组件用 useCallback 包裹函数,子组件用 memo 包裹组件,要不就都不用


// 子组件// callback 为父组件传过来的回调函数const Child = ({ callback }) => {}// 子组件用 React.memo 包裹export default React.memo(Child);
// 父组件 const Parent = () => { // 子组件的回调函数用 useCallback 包裹 const callback = React.useCallback(() => {}, []); return <Child callback={callback} />};
复制代码

Raect.memo 的局限

  • React.memo 包裹在组件上,可以对传给组件的属性进行判定,父组件导致子组件重新渲染的时候, memo 包裹的组件,会判定属性是否和上次渲染时候否改变,如果有改变,子组件重新渲染,否则不会重新渲染。

  • React.memo 有个局限,只能防止来源于外部的属性,如果是来源于内部的属性,React.memo 是无作用的,例如通过 useContext 直接注入组件内部的属性,它没法防止,可以看下下面这个简单的例子


export const Store = React.createContext(null);export default function Parent() {  const [state, dispatch] = useReducer(reducer, { count: 0, step: 0 });
return ( <Store.Provider value={{ dispatch, state }}> <Step /> <Count /> </Store.Provider> );}
export const Count = memo(() => { const { state, dispatch } = useContext(Store); const setCount = () => dispatch({ type: 'changeCount' }); return ( <> <span>count: {state.count}</span> <button onClick={setCount}>change count</button> </> );});
export const Step = memo(() => { const { state, dispatch } = useContext(Store); const setCount = () => dispatch({ type: 'changeStep' }); return ( <> <span>count: {state.step}</span> <button onClick={setStep}>change step</button> </> );});
复制代码


  • 上面的组件,count 或者 step 任意这个属性改变,都会导致两个子组件重新渲染,这显然是不对的。

React.useMemo 代替 React.momo

useMemomemo 一样,返回一个记忆化的值,如果依赖项没有改变,会返回上一次渲染的结果,它和 useCallback 的差别就在一个是返回记忆化的函数,一个是返回记忆化的值,如果 useMemo 的回调函数执行返回一个函数,那它的效果和 useCallback 是一样的。


因而上面的组件可以改一下,下面这种写法就可以防止任意一个属性改变会导致两个子组件重新渲染的问题


export const Count = () => {  const { state, dispatch } = useContext(Store);  const setCount = () => dispatch({ type: 'changeCount' });  return useMemo(    () => (      <>        <span>count: {state.count}</span>        <button onClick={setCount}>change count</button>      </>    ),    [state.count]  );};export const Step = () => {  const { state, dispatch } = useContext(Store);  const setStep = () => dispatch({ type: 'changeStep' });  return useMemo(    () => (      <>        <span>step: {state.step}</span>        <button onClick={setStep}>change step</button>      </>    ),    [state.step]  );};
复制代码

React.momoReact.useMemo

  • React.momo 在防止子组件重新渲染方面,是最简单的,在类组件里面有个 React.PureComponent,其作用也是。但是它无法检测函数内部的状态变化,并且防止重新渲染,例如 useContext 注入的状态。不过它自动比较全部属性,使用起来方面。

  • React.memo 按照依赖列表是否有属性改变,决定是否返回新的值,一定程度上和 Vue 的计算属性类似,但是需要说动声明依赖的属性。相比 React.momo,它的控制的粒度更细,但是一般的外部属性变化,用这个明显没有 React.memo 方便

useReducer useContext

  • useReduceruseState 的一种替代方案,useState 的内部实现就是 useReducer

  • 它接收两个参数,和 redux 一样,一个是 reducer, 一个是初始值,有两个返回,一直是当前的 state,一个是 dispatch

  • 通过 dispatch 调用 action 就可以修改 state 里面的数据

  • 本质的作用是,让数据和函数组件解耦,让函数组件只要发出 Action,就可以修改数据,由于数据不在组件内部,也不用处理内部 state 变化带来的 effect

  • useContextuseReducer 结合,一定程度上可以实现一个 React Redux

其他 Hook

  • useImperativeHandle ,搭配 useRefforwardRefs 可以实现定制父组件可以引用子组件的属性和方法,而不是直接引用整个子组件的实例,在父组件需要调用子组件属性和方法,但是又不想全部属性和方法都给父组件调用的时候使用

  • useLayoutEffect 使用的不多,作用和 useEffect 一样,但是这个 hook 是在组件变化后, DOM 节点生成后,渲染之前调用,区别于 useEffect 是渲染之后调用,不太推荐使用,会阻塞渲染

  • useDebugValue 可用于在 React 开发者工具中显示自定义 hook 的标签。类似 Vue 组件用的 name 或者 React 组件中的 displayName,不影响代码运行

组件复用

React Hook 有自定义 HookReact 类组件有高阶组件或者渲染属性


有个比较常见的场景,进入页面需要调用后端接口的问题,如果每个组件都写一次,很繁琐,假设处理数据的接口长这样子


interface Response<T> {  /** 是否有错误 */  hasError: boolean;  /** 是否有数据 */  hasData: boolean;  /** 是否请求中 */  Loading: boolean;  /** 请求回来的数据 */  data: T;}
复制代码

高阶组件

高阶组件(HOC)React 中用于复用组件逻辑的一种高级技巧。HOC 自身不是 React API 的一部分,它是一种基于 React 的组合特性而形成的设计模式。这种组件复用还挺常见的,比如 React-redux 里面的 connectReact RouterwithRouter


它可以做到:


  • 属性代理,比如多个组件都使用到的公共属性,注入属性

  • 包裹组件,比如将组件包裹在写好的容器里面

  • 渲染挟持,比如权限控制


用处


  • 代码复用

  • 性能监测 打点

  • 权限控制,按照不懂的权限等级,渲染不同的页面

高阶组件编写和使用

按上面请求的需求,做一个组件渲染完之后,就立即开始请求初始数据


function requestWrapper(options) {  return (Component) => {    return class WapperComponent extends React.Component {      constructor(props) {        super(props);        this.state = {          loading: false,          hasError: false,          hasData: false,          data: null,        };      }      httpRequest = async () => {        this.setState({ loading: true });        // 这里做一些请求工作,我这里就不写了,没必要        this.setState({ hasData: true });        this.setState({ hasError: false });        this.setState({          data: {            /** 这里是请求回来的数据 */          },        });        this.setState({ loading: false });      };      componentDidMount() {        this.httpRequest();      }      render() {        // 透传外部传过来的属性,然后合并 this.state,传给包裹组件        const combineProps = { ...this.props, ...this.state };        return this.state.loading ? (          <Loading />        ) : (          <Component {...combineProps} />        );      }    };  };}
复制代码


使用方面,高阶组件可以修饰类组件或者函数组件


function Page(props) {// props 包含 loading hasError hasData data prop1// 其中 prop1 来源于外部属性,其他的属性来源于包裹的组件}// 类组件使用,使用装饰器@requestWrapper({url: '', param: {} })class ClassComponent extends React.PureComponent {}// 类组件使用,不适用装饰器export default requestWrapper({url: '', param: {} })(ClassComponent);// 函数式组件使用const WrapPage = requestWrapper({ url: '', param: {} })(Page);export default WrapPage;// 使用<WrapPage prop1="1" />;
复制代码

自定义 Hook 的编写和使用

自定义 Hook 的编写,一个简单的数据请求的 Hook


function useRequest(options) {  const [loading, setLoading] = useState(false);  const [hasError, setHasError] = useState(false);  const [hasData, setHasData] = useState(false);  const [data, setData] = useState(null);  useEffect(() => {    async function reqeust() {      setLoading(true);      /** 这里依旧是请求,没写 */      setHasError(false);      setHasData(true);      setData({        /** 请求回来的数据 */      });      setLoading(false);    }    reqeust();  }, []);  return { loading, hasError, hasData, data };}
复制代码


自定义 hook 只能在函数式组件使用,不能在类组件里面用


function Page(props) {  // 这次的 props 只有 prop1 这个属性  const { loading, hasError, hasData, data } = useRequest({    url: ‘’,    param: {},  });}<Page prop1={1} />;
复制代码

函数式式组件和类组件默认属性

class Button extends React.PureComponent {  static defaultProps = { type: "primary", onChange: () => {} };}
// 不论是函数式还是类都可以这么玩,这也是类静态属性的另外一种写法Button.defaultProps = { type: "primary", onChange: () => {}};
function Button({ type, onChange }) {}
// 这样写看起来没问题,但是实际上,如果父组件没传 onChange,onChange// 每次组件渲染都会生成一个新的函数,引用类型都有这个问题function Button({ type = "primary", onChange = () => {} }) {}
// 这很OK,你可真是个小机灵鬼const changeMet = () => {}function Button({ type = “primary”, onChange = changeMet }) {}
复制代码

类组件的问题被解决了么?

复用组件状态逻辑难


  • 依赖自定义的 Hook,可以解决组件状态和逻辑复用的问题,但是自定义 Hook 编写需要对 Hook 运行机制非常了解,门槛并不比高阶组件低


生命周期带来的负面影响,逻辑拆分严重


  • 生命周期拆分逻辑的问题,在 Hook 里面切实被解决了,不会存在同一个逻辑被拆分在 N 个生命周期里面了


This 的指向问题


  • 这个问题在 Hook 里面也是解决了,因为函数没有 this,就不会有 this 的问题,但是相对的,如果需要一个不变的对象,请使用 useRef

简单总结

  • useState可以实现类似 statesetState 的效果

  • useEffect 可以实现 componentDidMount componentDidUpdate componentWillUnmount 这几个生命周期的功能,并且写法更加简单,在每次渲染后都会触发,触发的条件是依赖项有改变

  • useRef 返回一个引用,每次渲染都返回同一个对象,和类组件 this 属性一致

  • useCallback 返回一个记忆化的回调函数,在依赖项改变的时候,回调函数会修改,否则返回之前的回调函数,对于一些需要传给子组件的函数,可以使用这个,避免子组件因为回调函数改变而改变

  • useMemo 返回一个记忆化的值,依赖项改变,返回的值才会变,可用来记忆化值,和 Vue 计算属性类似,避免重复计算,避免重复渲染

  • 自定义的Hook是实现状态和逻辑复用,作用和高阶组件还有渲染属性差不多

  • useReduceruseState 的底层实现,可以管理多个 state,把 state 从组件内部抽离出来

  • useContext 可以实现批量传值,注入多个组件,和 useReducer useMemo 使用可以实现 Redux 的功能

使用感受

个人使用方面

  • 函数式组件本身写起来就比类组件少写不少代码

  • 闭包问题很影响开发和调试,提高了不少调试成本,如果不熟悉闭包机制,很难发现问题。Hook 中的闭包问题,大多还是由于依赖项没有填写导致

  • 闭包带来的问题,比类组件 This 的更加恼人,主要调试不好发现问题,填不填依赖项也是一个让人纠结的活

  • Hook 的依赖不能自动识别,必须手动声明,虽然有插件辅助添加,但是使用起来还是不如 VueHook

  • 在熟悉 Hook 的机制的情况下,Hook 开发体验还是比类组件好很多

团队协作方面

  • 其实在推广 Hook 的时候,团队成员的 Hook 水平是不太一致的,很多人员就遇到了闭包问题,还有依赖死循环的问题,这个可能大大小小都遇到过,就好像上面提到的,解决闭包问题,方式五花八门,其实也是我自己摸索过来的,然后看到团队成员其实差不多还使用者 state 更新之后,重新设置监听的方式,这个并不是太好,只能说闭包问题解决了

  • 相对的,React 官方也没有总结太多最佳实践,很多都靠自己实践过来的,所以团队成员在刚接触 Hook 的时候,都是 useEffect useState 两把 API,甚至在 React Hook 的官方文档里面 Hook 简介,对于这两个 Hook 介绍的很多

  • 但对于其他常用的 Hook,比如 useRefuseCallback 使用场景其实没有太好的例子去支撑这些 API 的使用。倒是其实团队里面不少成员,面对着不参与渲染的属性,也是用 useState ,而不是使用 useRef。就是很多新人接触 Hook 容易犯的一个错误。

  • 有不少同学有些插件没有装上,导致 React 自动检测依赖项的插件没有生效,这无疑会给本身就难以发现的闭包问题加了一层霜

  • 所以我也定期在团队里面分享我认为是比较好的实践,去引导团队里面的同学

  • 对于不喜欢用 React Hook 的同学,直接用类组件,类组件虽然代码写起来繁琐,但是起码没有闭包这些问题,而且代码被接手之后容易读懂,React Hook 只是一个工具,会使用会给你加分,但是不会使用只会用类组件,也不会对其他人代码有影响,比较类组件和函数组件是可以共存的


用户头像

xiaofeng

关注

努力写代码中 2022-08-18 加入

努力写代码中

评论

发布
暂无评论
React-Hook最佳实践_React_xiaofeng_InfoQ写作社区