关于前端:React-Hooks-指北

3次阅读

共计 12027 个字符,预计需要花费 31 分钟才能阅读完成。

前言

这篇文章旨在总结 React Hooks 的应用技巧以及在应用过程中须要留神的问题,其中会附加一些问题产生的起因以及解决形式。然而请留神,文章中所给出的解决形式并不一定齐全实用,解决问题的计划有很多种,兴许你所在的团队针对这些问题曾经给出了对应的标准,亦或是你曾经对这些问题的解决形式造成了更好的认知。所以你的着重点应该放在 你是否在应用 React hooks 过程中意识到了这些问题 以及 你对这些问题的思考

useState hook

初始化状态

如果组件的某个状态须要依附大量计算失去初始值,个别咱们会定义一个函数来初始化状态

  • 在 class 组件中

    state = {state1: calcInitialState()
    }

    没什么问题,在组件不被从新挂载的状况下,即便组件屡次从新渲染,calcInitialState 也只会被执行一次

  • 而在函数组件中, 有两种形式

    const state1 = useState(calcInitialState) // 组件屡次渲染时,calcInitialState 仅会被执行一次
    const state1 = useState(calcInitialState()) // 组件每次渲染时,calcInitialState 都会被执行

    函数组件每次从新渲染,都会执行函数组件自身,在第一次渲染时,useState 会读取初始值,如果是初始值函数,则会被执行,并且函数的返回值被作为初始状态,此时,这两种写法体现雷同。然而在后续从新渲染过程,useState 尽管不会读取默认状态值,也不会对默认状态值做任何解决,然而第二种写法中的 calcInitialState 依然会被执行,且是毫无意义的。外部运行流程见源码 mountState 和 updateState

状态的捕捉形式 (this & 闭包)

前段时间,我在团队外部分享了罕用的 React Hooks 原理以及源码,过后我提到了,不论是 class 组件还是函数组件,他们的状态都存储在组件对应的 fiber 上。函数组件 和 class 组件状态更新的流程如下图所示:

具体可见源码 updateClassInstance 和 updateReducer

对于渲染的那一部分(JSX)来说,能够始终拿到最新的的状态。只是获取状态的形式不同,class 组件是通过 this.state 指向 fiber 节点上存储的状态,函数组件则是通过 useState 这个函数的返回值获取,那么问题在于函数组件拿到的状态是存储在闭包中的,这个闭包由 useState 执行产生。

换个角度来说,对于函数组件,咱们须要特地器重“渲染”这个概念。函数组件每次渲染,其外部申明的函数或者是返回的 UI(JSX)都只能捕捉到以后这次渲染的 props 和 state,这对于 UI(JSX) 来说齐全没有问题。然而对于函数组件外部的函数,特地是提早回调的函数,须要特地留神等到回调函数执行时,回调函数中捕捉的 state 和 props 是否是你所冀望的。

想要防止函数组件中闭包问题带来的困扰,须要了解并记住上面两句话

  • 函数组件每一次渲染都有它本人的 props 和 state
  • 函数组件每一次渲染都有它本人的事件处理函数

状态粒度

状态粒度过细

在编写 class 组件时,简直不必思考状态粒度的问题,因为开发者总是能够一次性申明所有状态或者一次性更新所有状态,就像这样

handleClick = () => {
    this.setState({
    currentPage: 2,
    pageSize: 20,
    total: 100
  })
}

大多数开发者并不会蠢到应用三次 setState 去更新这三个状态,然而在函数组件中只能这样

const handleClick = () => {setCurrent(2)
  setPageSize(20)
  setTotal(100)
}

看到这段代码,嗯,可能会让人感觉到有点不难受,问题就出在更新粒度过细,事实上一个分页组件的 currentPage,pageSize,total 常常会须要同时被更新,然而屡次触发 setXXX 还是会让人感到隐隐的不安,即便屡次触发更新可能会被 React 的 batchUpdate 机制合并为一次,然而当 setXXX 办法执行脱离了 React 的上下文时会触发屡次更新,例如异步完结时的回调中。

此时,咱们能够在 useState 中存储一个对象,将相关联的状态放在一起。也能够应用 useReducer 来治理多个状态。

状态粒度过粗

当一个 state 有肯定的复杂度的时候,我并不举荐暴力的将 class 组件申明 state 的形式硬生生塞到 useState 中,因为这兴许会将 class 中 state 的粒度过粗的缺点引入进来

问题一:难以发现能够被复用的状态逻辑

当一个组件的状态越来越多,组件的可读性和可维护性就会越来越差,不少人应该都深有体会,就像这样:

ps:截取自实在的业务代码

class XXX extends React.Component{constructor(props: any) {super(props);
    this.state = {tableListMap: {},
      showPreview: false,
      showRegModal: false,
      dataSource: [],
      columns: [],
      tablePartitionList: [],
      incrementColumns: [],
      loading: false,
      isChecked: {},
      isShowImpala: false,
      tableListSearch: {},
      schemaList: [],
      fetching: false,
      tableListLoading: false,
      bucketList: [],
      showPreviewPath: false,
      previewPath: '',
      currentObject: {object: [''], index: 0, bucket:'' },
      isCompressed: false,
      matchType: null
    };
  }
}

试想一下,在函数组件中,一个 useState 外面被塞进如此多的状态,且不谈是否发现其中可复用的状态和逻辑,即使你慧眼如炬,发现了它跟其余组件之间有能够复用的状态和逻辑。大概率,也很难在保障在以后组件(历史代码)不会出问题的状况下将能够复用的状态逻辑提取进去。

问题二:将无关的状态放在同一个 useState 中可能让状态更新变得不好管制

举个例子,如果页面上有个按钮,当点击这个按钮时,须要同时从不同的接口中拿到两份数据并渲染到页面上,此时如果这两份数据被寄存在同一个 useState 中

function DataViewer (props) {const [dataMap, setDataMap] = useState({data1: undefined, data2: undefined})
  
  const loadData1 = async () => {if (visible) {const data1 = await fetchData1()
      setDataMap({...dataMap, data1})
    }
  }

  const loadData2 = async () => {if (visible) {const data2 = await fetchData2()
        setDataMap({...dataMap, data2})
    }
  }
  
  const handleClick = () => {loadData1()
    loadData2()}
 // ...
}

问题很显著,只有两个申请都实现,无论胜利还是失败,dataMap 中都不会有先实现的那个申请返回的数据。

在 class 组件中,基本上是不会呈现这种问题的,因为总是能够通过 this 拿到以后的最新的状态,不会呈现屡次更新中状态笼罩的问题。当然在函数组件中,也能够应用 useRef 暂存接口数据,而后一起更新状态,但须要额定写一些逻辑,这里就不介绍这种黑科技了。

此时第一个解决办法是,将第一个接口返回的数据用变量暂存起来等到第二个接口实现再去更新 dataMap,就像这样

const loadDataMap = async () => {if (visible) {const data1 = await fetchData1()
     const data2 = await fetchData2()
     setDataMap({data1, data2})
  }
}

这样做带来的问题是,肯定要等第一个申请实现,能力去发动第二个申请,对于用户体验来说并不敌对。

好吧,想要两个接口并行,还能够应用 Promise.allSettled  并行的解决两个申请

const loadDataMap = () => {if (visible) {Promise.allSettled([ fetchData1(), fetchData2()])
        .then(results => {//...})
  }
}

看起来如同没什么问题了。然而,如果对接口的状态、返回值等有额定的解决逻辑时,你就须要将所有的接口的解决逻辑都塞到 .then 的回调中,并且这种办法肯定要两个接口都实现能力更新状态而后在页面中展现数据,也无奈独自的检测到其中的某一个接口是否处于 pending 状态,这种形式仿佛也不是那么敌对。

这样看来比拟完满的解决形式只有两种

  1. 避开闭包,你只须要在更新状态时传入一个函数就能够了,就像这样

    setDataMap(dataMap => ({ ...dataMap, data2}))
  2. 状态切分

    const [data1, setData1]  = useState()
    const [data2, setData2]  = useState()

解决问题的形式往往不止一种,你须要依据理论的业务状况自行去抉择你认为更加适合的形式。

如何设计状态粒度

官网文档的 QA 中如是说道

把所有 state 都放在同一个 useState 调用中,或是每一个字段都对应一个 useState 调用,这两形式都能跑通。当你在这两个极其之间找到均衡,而后把相干 state 组合到几个独立的 state 变量时,组件就会更加的可读。如果 state 的逻辑开始变得复杂,咱们举荐 用 reducer 来治理它,或应用自定义 Hook。

集体认为,聚合相干的状态,拆分无关的状态,是一种比拟好的实际形式。比方将分页器组件的  currentPage、pageSize、total  三个状态放在同一个 useState 中,将不同申请的返回的数据拆分到不同的 useState 中。另外还有一些状况是,状态的逻辑比较复杂,这个时候也能够应用 useReducer 来治理状态,这样就能够将一些简单的逻辑抽离到 reducer 中。

状态更新的两种形式

不论是函数组件还是 class 组件,更新状态的形式都有两种:setState(newState)   和  setState(oldState => newState),它们之间的差别在于,一个重视后果,一个重视目标。setState(newState)  用于形容新的状态,而 setState(oldState => newState) 用于形容新的状态与旧的状态相比该当做出什么样的扭转。

这样说可能有点形象,简略来说 setState(newState)  是用新的状态替换掉旧的状态,setState(oldState => newState) 是用来通过旧的状态计算出新的状态

useEffect hook

为什么须要 useEffect

从实践上讲函数组件就是单纯的用来渲染的,也就是所谓的纯函数,事实上没有 React Hooks 之前也的确是这样的。而其余的操作如数据获取,设置定时器,批改 DOM 等都被称作副作用。

为什么不能间接在函数组件内间接执行副作用?

  • 有一些副作用操作可能会影响到渲染,如批改 DOM
  • 有一些副作用操作是须要革除的,如定时器
  • 如果间接在函数组件外部间接进行副作用操作,那么函数组件每次从新渲染时都会执行这些操作,没法管制这些操作何时执行何时不执行

useEffect 怎么解决这些问题?

  • useEffect 包裹的函数会在浏览器渲染实现之后执行,保障不会影响到组件的渲染。另外被 useEffect 包裹的函数执行脱离了函数组件自身的执行上下文,所以不会对函数组件自身的执行造成影响
  • useEffect 包裹的函数能够 return 一个函数,用于革除副作用
  • useEffect 能够传入依赖项数组,当依赖项变动时才去执行副作用操作

useEffect 的这些个性有点像事件回调,只不过事件回调函数的触发依附 dom 事件如点击、输出等,而 useEffect 包裹的函数登程依附依赖项的变动。很多时候,将一些副作用操作放到事件回调函数中去执行是更好的抉择,这样就能够不必思考 useEffect 依赖项的问题了。

useEffect 是如何捕捉 props 和 state 的

useEffect 包裹的函数中,捕捉 props 和 state 的形式跟一般函数没两样,依赖于函数组件自身的执行上下文。useEffect 外部并没有做什么如数据绑定、依赖 fiber 等特地的事件。因而,函数组件每一次渲染都有它本人的 effects。在函数组件渲染实现后,产生的 effects 会被存储到组件对应的 fiber 上,期待特定的机会执行这些 effects(副作用)。即使是 effects 中某些异步回调执行时,页面曾经从新渲染了很屡次了,这些异步的回调函数中捕捉的 props 和 state 还是产生这些 effects 的那次渲染中组件的 state 和 props。

useEffect 的依赖项

哪些应该被放在 useEffect 的依赖项中

实践上来说。useEffect 的心智模型更靠近于 effect 在某些值变动时去执行,然而有的时候为了保障 effect 中捕捉的 props 和 state 是你所冀望的,你不得不将 effect 中用到的所有的组件内的变量都放到依赖项中。如果你在我的项目中设置了对应的 lint 规定,lint 工具也会通知你应该这样做,然而这如同与 useEffect 的心智模型产生了一些抵触。

这种抵触带来的结果是,effect 可能会频繁的执行,如下例是一个每秒递增的计数器

function Counter () {const [count, setCount] = useState(1)
  useEffect(() => {const timerId = setInterval(() => {setCount(count + 1);
    }, 1000);
    return () => clearInterval(timerId);
  }, [count]);
  
  return (<>{count}</>
  )
}

如果移除了这个 useEffect 的依赖项中的 count,那么定时器中的回调函数就会始终执行 setCount(1 + 1),这不是咱们所冀望的。好吧,诚实一点,将 count 放到依赖项中,然而此时定时器会被频繁的革除和创立,这可能会影响定时器回调的触发频率,这也不是咱们所冀望的。

到当初为止,问题还是没有失去解决,我还是偏向于 useEffect 的依赖项是用来触发 effect 的,而不是用来解决闭包问题的,那么只能想方法移除掉 useEffect 对 count 的依赖。

如何缩小 useEffect 的依赖项

  • 打消 useEffect 中不必要的捕捉
    如上例中的 useEffect 能够写成

    useEffect(() => {const timerId = setInterval(() => {setCount(count => count + 1);
      }, 1000);
      return () => clearInterval(timerId);
    }, []);
  • 将依赖从 effect 中解耦
    还是这个定时计数器的例子,如果咱们想要通过 props 传递一个 step 属性给这个组件,用来通知这个组件每秒递增的值的大小
<Counter step={2}/>

于是 Counter 组件就变成了这样

function Counter ({step}) {const [count, setCount] = useState(1)
  useEffect(() => {const timerId = setInterval(() => {setCount(count => count + step);
    }, 1000);
    return () => clearInterval(timerId);
  }, [step]);
  
  return (<>{count}</>
  )
}

当初的问题是,当 step 的值变动时,依然会重启定时器。当初该 useReducer 上场了。

function Counter ({step}) {const [count, dispatch] = useReducer(reducer, 0);

  function reducer(state, action) {if (action.type === 'tick') {return state + step;} else {throw new Error('type in action is not true');
    }
  }

  useEffect(() => {const timerId = setInterval(() => {dispatch({ type: 'tick'});
    }, 1000);
    return () => clearInterval(timerId);
  }, []);
  
  
  return (<>{count}</>
  )
}

那么当初问题来了:
Q:_为什么咱们能够不在依赖项中退出 _dispatch_?_
A:因为 React 向咱们保障了 _dispatch_ (来源于 useReducer)setState (来源于 useState)以及 ref.current (来源于 useRef) 即便组件屡次渲染,它们的援用地址也不会扭转
Q:_为什么 reducer 中捕捉到的 step 值是最新的?_
A:对于 useReducer 来说,React 只会记住它的 action,并不会记住它的 reducer。也就是说每次组件从新渲染执行 useReducer 时, 它都会从新读取 reducer
上文中说过 useStateuseReducer 在源码中是同一个货色。在理论应用过程中,相比于 useState,useReducer 能够让咱们把 更新逻辑(reducer)形容产生了什么(action)离开,而这一点正好能够用来移除不必要的依赖。

当然理论业务中,很少会碰到上述例子中的场景,绝大部分状况下,都只用将想要 用来触发执行 effect 的值 放到 useEffect 的依赖项中。对于无奈用下面讲的两种办法解决的场景,也能够通过 useRef 来绕过烦人的闭包问题。

函数该当作为 useEffect 的依赖项吗

个人观点是:绝大多数状况下,不应该将函数作为 useEffect 的依赖项。至于是否安心的将函数从依赖项中移除次要看

  • 函数是否参加 React 的数据流
    简略来说,就是看这个函数中是否用到了函数组件外部的变量(useRef 除外)。如果一个函数并没有参加 React 数据流,然而在 useEffect 中用到了,此时你应该将这个函数提取到组件内部,这样你就能够在 useEffect 的依赖项中无脑移除掉这个函数。
  • 函数是否被异步提早调用
    函数被提早调用的状况下很容易产生闭包问题,这时即便将函数作为 useEffect 的依赖项,也无奈解决闭包问题,反而可能减少 effect 的触发频率,下文中的 应用可变数据代替不可变数据 一节会介绍一种办法可能保障在函数援用地址不变的状况下,使函数主动捕组件内变量最新的值的办法。

大多数状况下都能够不必将函数放在 useEffect 的依赖项中。兴许有一些极其非凡的业务场景,这时只能将函数用 useCallback 包裹,而后放到 useEffect 的 依赖项中 (我目前没碰到过这种状况)

useRef hook

依照我集体的了解,useRef 更像是 class 组件的实例属性,即 this.xxx。在函数组件中,useRef  能够看做是一个容器,你能够任意操作这个容器中的数据,并且这个容器中的援用地址不会因为组件屡次从新渲染而扭转。在我看来它就是函数组件的作弊器同时也是解决函数组件中闭包问题的绝世利器。

useRef 的特色

  1. 当 useRef  存储的值变动时,并不会引起组件从新渲染
  2. 能够用来寄存可变数据,在组件屡次渲染时,能放弃 ref (useRef 返回值)自身的援用地址不变

ps:useRef 能保障返回值援用地址不变的起因是,即便组件屡次渲染,useRef 返回的 ref 还是第一次执行时返回的那个 ref。在函数组件第一次渲染时,React 外部会将 useRef 的返回值 (ref) 存储在组件对应的 fiber 节点上,后续组件从新渲染时,React 外部不会对 useRef 做任何解决,间接返回  fiber 节点上存储的 ref。详情可见源码 mountRef  和 updateRef

基于 useRef 的特色能够做什么

  • 实现一个自定义 hook 用来统计组件的渲染次数

    const useRenderTimes = () => {const ref = useRef(0)
      ref.current += 1
      return ref.current
    }
  • 记录上一次组件渲染时的某个值

    const usePreValue = (value) => {const ref = useRef(undefined)
      const preValue = ref.current
      ref.current = value
      return preValue
    }
  • 避开不必要的从新渲染
    如果某一个状态与渲染无关,那么你能够应用 useRef 代替 useState。还记得上述 useEffect 那一节中的 Counter 组件吗,如果将 count 作为 useEffect 的依赖项,那么定时器会不停的创立 / 销毁,下面给出了两种解决办法,当初咱们来说说另一种形式。思路是,只有在 count 变动时,不从新渲染组件就好了,那么能够应用 useRef 存储 count 值,当然,这种形式的仅限于 count 不参加渲染的状况,或者也能够在 useRef 中存储值扭转的同时去触发组件从新渲染
    这样的场景其实很常见,比方有一个表单,当用户在表单中填写实现后,点击 submit 按钮,将数据通过接口发送给后端。如果这个表单不是一个受控组件,那么相比于 useState 用 useRef 存储表单数据是个更好的抉择,因为 它不会导致不必要的从新渲染

    function Counter () {const count = useRef(1)
      useEffect(() => {const timerId = setInterval(() => {count.current += 1;}, 1000);
     return () => {clearInterval(timerId)
       console.log(count.current)
     };
      }, []);
    }

有时在函数组件中应用 useState 是为了在组件从新渲染之后依然能拿到某个值,但咱们心愿让这个值变动时不要触发组件更新,亦或是想防止 useState 的不可变数据导致的闭包问题,那么这个时候就是应用 useRef 的机会。

如何对待 useRef

在我看来在 React Hooks 中 useRef 最起码与 useState是等同重要的,知乎有篇文章中的一句话这样说,

每一个心愿深刻 hook 实际的开发者都必须记住这个论断,无奈自若地应用 useRef 会让你失去 hook 将近一半的能力。

示意认同。

useCallback Hook

对于 useCallback,官网上的介绍是

把内联回调函数及依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项扭转时才会更新。当你把回调函数传递给通过优化的并应用援用相等性去防止非必要渲染(例如 shouldComponentUpdate)的子组件时,它将十分有用。

又看到了相熟的词汇 -“依赖项 ”,要想保障在 useCallback 中包裹的函数捕捉到以后渲染时函数组件外部的值,必须将 useCallback 包裹的函数中所有援用到的函数组件外部的值都放到依赖项中。另外,请留神官网介绍的 useCallback 的作用是 -“ 性能优化”。

你真的须要为函数组件中的每一个函数都包裹上 useCallback 吗?就拿官网文档中的 shouldComponentUpdate 举例,咱们在函数组件中定义了一个函数 handleClick 并用 useCallback 包裹,而后通过 props 传递给子组件,子组件中通过shouldComponentUpdate 比照 handleClick , 决定是否须要更新。

function Parent () {const handleClick = useCallback(()=>{//...},[...])
  
  return (<Child handleClick={handleClick}/>)
}

class Child extends React.Component {shouldComponentUpdate(nextProps) {return this.props.handleClick !== nextProps.handleClick}
  // ...
}

那么此时应该有一个疑难,性能晋升到底有多大,如果你感兴趣的话,无妨动起手来,写个示例,比照一下 performance 性能面板,你应该看到 useCallback 对性能晋升到底有多大,同时依据测试后果,能够大略失去什么时候应该用 useCallback 来晋升性能。useCallback 的另一个作用是能够维持函数援用地址不变。然而它依然会在依赖项变动时从新生成函数,想要维持函数援用地址始终不变还要是要应用 useRef

我冲突 useCallback 的起因是,在我看来它自身的作用比拟鸡肋,而且应用 useCallback,必须留神依赖项,这又还会带来额定的心智累赘。

useMemo Hook

应用 useMemo 时须要留神的点不多,官网文档也写的十分明确了

useMemo 的作用是

把“创立”函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项扭转时才从新计算 memoized 值。这种优化有助于防止在每次渲染时都进行高开销的计算。

请把着重点放在“高开销的计算”上,有的时候,可能也并不需要 useMemo

应用 useMemo  时须要留神的是

你能够把 **useMemo** 作为性能优化的伎俩,但不要把它当成语义上的保障。未来,React 可能会抉择“忘记”以前的一些 memoized 值,并在下次渲染时从新计算它们,比方为离屏组件开释内存。先编写在没有 useMemo 的状况下也能够执行的代码 —— 之后再在你的代码中增加 useMemo,以达到优化性能的目标。

函数组件中的闭包问题

联合上文,能够总结出在函数组件中,闭包问题次要是因为函数的提早调用,不论是 useEffect 包裹的函数还是定时器回调函数亦或者是异步申请的回调函数,它们 外部捕捉到的变量 都是 存在内部函数组件执行时产生的闭包中,那么想要躲避闭包带来的困扰,思路有两个

缩小函数外部对外部变量的依赖

比方上述定时计数器例子中

setCount(count + 1)
// 替换为
setCount(count => count + 1)

应用可变数据代替不可变数据

在 class 组件中很少遇到闭包的困扰是因为在 class 组件中拜访组件的 state 和 props 都是通过 this,尽管 this.state 和 this.props 指向的是是不可变数据,然而 this 外部存储的数据是可变的并且 this 的援用地址不会产生扭转。那么函数组件中有没有相似 this 的货色呢?有,useRef

  • 对于非函数类型,能够应用 useRef 代替 useState
    这样即便是提早调用的函数,也能够通过 ref.current 取到最新的值,因为提早调用的函数外面取的是 useRef 返回值的援用地址。上文中的例子中也这样用过了。须要留神的是,如果 useRef 中存储的值参加了渲染,比方

    function demo () {const text = useRef("")
      return <>{text.current}</>
    }

这时,更新 useRef 中存储的值,并不会引起视图从新渲染。然而咱们能够通过更新另一个状态 (useState) 来使视图同步。如果在组件中切实找不到一个能够在 useRef 外部的值变动时去触发更新的状态,那么也能够写一个自定义 hook 去强制触发更新

function useForceUpdate () {const [, forceUpdate] = useReducer(x => x + 1, 0)
  return forceUpdate
}

封装一下,就能够失去一个存储可变数据的 useState

function useMutableState (init) {const stateRef = useRef(init)
    const [, updateState] = useReducer((preState, action) => {
          stateRef.current = typeof action === 'function'
            ? action(preState)
            : action
          return stateRef.current
    }, init)

    return [stateRef, updateState]
}
  • 对于函数类型,也能够通过 useRef 放弃函数援用地址不变,函数外部主动捕捉最新的值

    function useStableFn(fn, deps) {const fnRef = useRef();
      fnRef.current = fn;
      return useCallback(() => {return fnRef.current();
      }, []);
    }

结语

在 React Hooks 中很难总结出真正完满的最佳实际,就连官网文档和博客上也只是形容了 React Hooks 的心智模型。上文中的有些观点或者示例违反了官网给出的心智模型,不得不抵赖我是 useRef 的爱好者。然而对于 React Hooks 的实际来说,没有银弹。重要的是,你是否了解 hooks 是如何工作的,以及你有没有本人的避坑指南。

参考链接

  • React 官网文档
  • a-complete-guide-to-useeffect
  • 张立理 @知乎 React Hooks 系列文章
正文完
 0