「评论有奖」自定义Hook会很难吗?

7,175 阅读11分钟

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

跟往常一样,在一个功能完成后我发起一个PR,关电脑下班。第二天却发现PR被拒了,理由是:“请用自定义Hook抽取几个组件的通用逻辑”。

作为一个React初学者,对自定义Hook还是有点惧怕的。

等leader上班后,跟他商量:“可不可以先通过PR,等后面学会了自定义Hook后再去抽取。”

leader反问我:“Hook的作用是什么?”。

我很快得回答:“让函数式组件拥有自身的状态和生命周期,在没有使用Class的情况下使用React特性。”

leader严肃地说:“你是不是忘了Hook的另一个重要的作用,让组件的通用逻辑复用更简单,避免使用HOC来复用组件的通用逻辑带来嵌套地狱的问题。逻辑复用是必不可少的,否则会导致项目中充斥着大量的重复代码。这点我是不接受的。你一定要用自定义Hook把几个组件的通用逻辑抽取出来后,再提交PR。”

既然leader强烈要求,只好加班加点去学一下如何自定义Hook。

我们在使用任何一个框架、JS库、UI库时,遇到自定义的部分,都不愿意去使用它,因为要使用它,必须得去了解其自定义的规则,这里面往往都是麻烦。

那么React中的自定义Hook的规则会很麻烦吗?在花了一周的时间去了解它使用它,最后发现也不过如此。于是在本文中把我学习的过程分享出来,欢迎各位掘友评论指正哈,评论有奖噢!具体看文末。

什么是自定义Hook

自定义 Hook 是一个函数,其名称以 “use” 开头,函数内部可以调用其他的 Hook。

以上是React官方的定义,我们可以很明确,自定义一个Hook就是开发一个函数,这个函数只是一个普通的函数,不是什么构造函数、自执行函数之类的。

那要开发一个什么样的函数?一个函数的组成有函数名、参数、函数内部、返回值,我们先结合React官方文档中对自定义Hook的要求,对其每个部分分析一下。

  • 函数名:以 “use” 开头,比如useState。为什么要以 “use” 开头。这是因为,如果不以 “use” 开头的话,无法判断这个函数是否包含对其内部 Hook 的调用,React 将无法自动检查这个自定义Hook是否违反了 Hook 的规则。

  • 参数:并没有特殊要求,只是React官网中有提到过,可以接收另一个Hook的返回值作为参数,实现在多个 Hook 之间传递信息的功能。

  • 函数内部:在函数内部可以调用其他的Hook,但是要遵循两个规则:

    • 只在最顶层使用 Hook ,不要在循环,条件或嵌套函数中调用 Hook,这是因为 React 是靠 Hook 调用的顺序来知道哪个 state 对于哪个 useState 的。具有看官网这里的解释。

    • 不要在普通的 JavaScript 函数中调用 Hook,只能在 React 函数中调用 Hook。这一点很好理解吧,Hook 本身就是由 React 提供的。

  • 返回值:没有限制一定要返回什么,当然一个函数默认返回一个undefined

自定义Hook的场景

在开头提到,在几个组件中存在一些通用逻辑,要用自定义Hook的方式将其提取出来,这就是自定义Hook的场景。而且在React官网中也指出:“通过自定义 Hook,可以将组件逻辑提取到可重用的函数中。”

我们要思考一下什么是组件中的通用逻辑。在Vue工程中,认为通用逻辑就是工具类函数,比如深拷贝、防抖函数、节流函数、获取链接指定参数等等,我把它们提取到util.js中。

但是一旦涉及到业务方便,比如一段通用逻辑中请求了接口,往往就不进行提取了。比如在Vue工程中,把这些通用逻辑提取到混入(mixin)中。

然而在React中,我们把跟业务相关的通用逻辑提取为一个自定义Hook,Hook也是一个函数,函数式组件也是一个函数,在函数中调用函数,使用比混入(mixin)更简单。当然工具类方面的通用逻辑也可以提取为一个自定义Hook。

正如React官网所说:“当我们想在两个函数之间共享逻辑时,我们会把它提取到第三个函数中。而组件和 Hook 都是函数,所以也同样适用这种方式。”

通用逻辑有很多种,自定义Hook是一个函数,定义时要要遵循单一职责原则。例如把通用逻辑处理的场景进一步区分为UI交互、副作用、生命周期、数据处理、DOM处理、优化处理等等场景。

自定义一个最简单的Hook

const Demo = () =>{
  useEffect(() => {
     console.log('组件首次渲染')
  },[]);
}
export default Demo;

以上绝对是组件中最通用的逻辑。useEffect的第二参数接收一个空数组表示只在组件首次渲染时执行作为第一参数传入useEffect的方法。那我们把这个通用逻辑通过自定义一个名叫useMount的 Hook 提取出来,实现如下所示:

import { useEffect } from 'react';

const useMount = (fn: () => void) => {
  useEffect(() => {
    fn();
  }, []);
};

export default useMount;

useMount这个自定义Hook接收函数fn作为参数,在组件首次渲染时执行函数fn

如何使用自定义Hook

自定义Hook是定义在一个js或ts文件中,最后用export导出,故用import引入使用:

import useMount from '@/hooks/useMount';

const Demo = () =>{
  useMount(() =>{
     console.log('组件首次渲染')
  })
  return(
    <div>demo</div>
  )
}
export default Demo;

自定义Hook的内部

React提供了10个内部Hook,在自定义Hook的内部,一般都是利用这10个内部Hook,进行组装和扩展,来自定义各种功能的Hook。

初学者在这里往往有个疑问,比如在一个自定义Hook useMyState中使用useState定义了一个a变量。

import { useState } from 'react';

const useMyState = () => {
 const [a,setA] = useState();
 return [a,setA]
};

export default useMyState;

然后在使用useMy的组件中又使用useState定义了一个a变量,这样会不会引起冲突。

import useMyState from '@/hooks/useMount';

const Demo = () =>{
  const [a,setA] = useState();
  const [b,setB] = useMyState();
  return(
    <div>demo</div>
  )
}
export default Demo;

完全不要有这样的担忧,两个Hook之间是完全隔离的,在一个组件中可以多次使用useStateuseEffect,它们是完全独立的。而且Hook也是个函数,有函数作用域在兜底。

疑问解除后,现在如何利用10个内部Hook来自定义新的Hook。举一个非常简单的例子,我们在开发中要让一个值改变之前的值也展示出来。一般会这么做:

const Demo = () =>{
  const [current , setCurrent ] = useState(1);
  const [previous , setPrevious ]= useState(0);
  const updata = () => {
    setCurrent(value =>{
      setPrevious(value);
      return value+1;
    })
  }
  return (
    <div>
      <div>{current}</div>
      <div>{previous}</div>
      <div onClick={updata}>改变</div>
    </div>
  );
}
export default Demo

其中使用两个useState创建了当前current和之前previous的两个变量,在改变current时,把current之前的值赋值给previous。虽然这么实现也可以,但是有点不优雅,假如还要对current之前的值进行一些判断再赋值给previous,那是不是在改变currentsetCurrent方法中要写很多不相干的东西,有点违背单一原则。

这时候我们就可以自定义一个Hook来专门处理这种业务场景,我把这个Hook命名为usePrevious

import { useRef } from 'react';
const usePrevious = (state, compare) =>{
  const prevRef = useRef();
  const curRef = useRef();

  const needUpdate = typeof compare === 'function' ? compare(curRef.current, state) : true;
  if (needUpdate) {
    prevRef.current = curRef.current;
    curRef.current = state;
  }

  return prevRef.current;
}

export default usePrevious;

其中利用useRef来保存一个值的旧值和新值,比之前用useState来保存好。原因在于useRef返回一个可变的 ref对象,其current属性被初始化为传入的参数。返回的ref对象在组件的整个生命周期内保持不变。

ref对象内容发生变化时,useRef并不会通知你。也就是变更.current属性不会引起组件重新渲染, 这点很重要,而useState创建的值只要发生改变就会引起组件重新渲染。

HookusePrevious接受一个数据state和一个函数compare,函数compare接收数据的旧值和新值,可以在函数内进行判断后,再决定是否赋值给prevRef.current,HookusePrevious最后返回prevRef.current

那如何使用usePrevious呢,示例如下所示:

import usePrevious from '@/hooks/usePrevious';
const Demo = () =>{
  const [current , setCurrent ] = useState(1);
  const compare = (oldValue,newValue) =>{
     if(oldValue !== newValue){
       return true;
     }
  }
  const previous = usePrevious(current,compare);
  const updata = () => {
    setCurrent(value =>{
      value = value + 1;
      return value;
    })
  }
  return (
    <div>
      <div>{current}</div>
      <div>{previous}</div>
      <div onClick={updata}>改变</div>
    </div>
  );
}
export default Demo

自定义Hook和React内部Hook有必然关系吗

到这里,我们再思考一个问题,一定要用React内部Hook开发自定义Hook吗?

答案当然是不一定了。为什么呢?我们再来看一下usePrevious的使用,usePrevious并没有像useState提供更新值的方法,那为什么usePrevious的返回值previous会实时更新。

这就要说到函数式组件的特性了,函数式组件的函数体就是类组件中的render(),当组件的state或props改变时,组件会重新渲染,函数式组件的函数体会重新执行一遍。

那么当current改变时,Demo这个函数会重新执行一遍,执行到const previous = usePrevious(current,compare);usePrevious接收到新的current,自然返回新的previous,这样就实时更新了。

好那么现在自定义一个Hook useMyCount 如下所示:

const useMyCount = (state) =>{
  return state + 1;
}

export default useMyCount;

这种自定义Hook可以吗?我们使用一下。

import useMyCount from '@/hooks/usePrevious';
const Demo = () =>{
  const [num , setNum ] = useState(1);
  const count = useMyCount(num);
  const updata = () => {
    setNum(value =>{
      value = value + 1;
      return value;
    })
  }
  return (
    <div>
      <div>{num}</div>
      <div>{count}</div>
      <div onClick={updata}>加一</div>
    </div>
  );
}
export default Demo

会发现Demo组件中count也会实时更新,而在自定义Hook useMyCount中,有没有使用React内部Hook没有丝毫关系。

再比如我自定义的一个获取当前时间戳的Hook:

const useTime = () =>{
  return new Date().getTime();
}

export default useTime;

自定义一个请求任务列表的Hook:

import * as Api from '@/api'
const useTask = async () =>{
  const res = await Api.getTask();
  return res;
}

export default useTask;

这些自定义Hook内部都没有用到React的内部Hook,所以说自定义Hook不难

关于自定义Hook一些要求

自定义Hook相当写一个函数,如果其内部要使用React的内部Hook,要遵循Hook的使用规则外。

此外还要符合书写函数的一些要求,比如对参数的定义,对返回值结构的要求。

最后要注意最重要的一点,一个Hook只负责一件事情,即遵循单一原则。

  • 参数方面的要求

    • 无参数

      允许 Hooks 无参数。

      const time = useTime();
      
    • 单参数

      单参数无论是否必填直接输入。

      const a = useA(parame);
      
    • 多必选参数

      必选参数小于 2 个,应平级输入。

      const a = useA(parame1, parame2);
      

      如果多于 2 个,应以 object 形式输入。

      const a = useA( {parame1:1, parame2:2, parame3:3 } );
      
    • 多非必选参数

      多非必选参数以 object 形式输入。

      const a = useA({parame1?, parame2?, parame3?, parame4?});
      
      
    • 必选参数 + 非必选参数

      必选参数在前,非必选参数在后。

      const a = useA(parame,{parame1?, parame2?, parame3?, parame4?});
      
      
  • 返回值结构的要求

    • 无输出

      允许 Hooks 无输出,一般常见于生命周期类 Hooks。

      useMount(() => {});
      
    • value 型

      Hooks 输出仅有一个值。

      const a = useA();
      
    • value setValue 型

      输出值为 value 和 setValue 类型的,结构为 [value, setValue] 。

      const [state, setState] = useA(a);
      
    • value actions 型

      其中actions为操作数据的方法。

      输出值为单 value 与多 actions 类型的,结构为 [value, actions] 。

      const [value, { actions1, actions2, actions3}] = useA(...);
      
    • values 型

      输出值为多 value 类型的,结构为 {...values}

      const {value1, value2, value3} = useA();
      
    • values actions 型

      输出值为多 value 与多 actions 类型的,结构为 {...values, ...actions} 。

      const {value1, value2, actions1, actions2} = useA(...);
      

遵循以上要求,自定义出来的Hook使用起来更方便

结语

单纯地从自定义Hook来讲,其实并不难。难是在于对组件中通用逻辑的判别和提取。

以上就是我在学习如何自定义Hook过程中的一些心得体会,如果掘友觉得有帮助的话点个赞支持一下,如果发现有错误的地方,非常欢迎在评论中指出,如果有更好的心得体会,恳请在评论中留言,一起进步哈。

「欢迎在评论区讨论,掘金官方将在掘力星计划活动结束后,在评论区抽送100份掘金周边,抽奖详情见活动文章」