React Hooks 原理与最佳实践

1. 前言

React Hooks 是 React 16.8 引入的新特性,容许咱们在不使用 Class 的前提下使用 state 和其余特性。React Hooks 要解决的问题是状态共享,是继 render-props 和 higher-order components 以后的第三种状态逻辑复用方案,不会产生 JSX 嵌套地狱问题。vue

2. 状态逻辑复用

通常来讲,组件是 UI 和逻辑,可是逻辑这一层面却很难复用。对用户而言,组件就像一个黑盒,咱们应该拿来即用。但当组件的样式或者结构不知足需求的时候,咱们只能去从新实现这个组件。node

在咱们开发 React 应用的时候,常常会遇到相似下面这种场景,你可能会有两个疑问:react

  1. Loading 是否能够复用?
  2. Loading 该怎么复用?

这几个例子都指向了同一个问题,那就是如何实现组件的逻辑复用?web

2.1 render props

将函数做为 props 传给父组件,父组件中的状态共享,经过参数传给函数,实现渲染,这就是 render props。使用 render prop 的库有 React Router、Downshift 以及 Formik。如下面这个 Toggle 组件为例子,咱们通常能够这样用:api

能够看到,控制 Modal 组件是否展现的状态被提取到了 Toggle 组件中,这个 Toggle 组件还能够拿来屡次复用到其余组件里面。那么这个 Toggle 是怎么实现的呢?看到实现后你就会理解 render props 的原理
数组

关于 render props 的更多内容能够参考 React 中文网的相关章节:Render Props
浏览器

2.2 higher-order components

higher-order components 通常简称 hoc,中文翻译为高阶组件。从名字上就能够看出来,高阶组件确定和高阶函数有什么千丝万缕的关系。高阶组件的本质是一个高阶函数,它接收一个组件,返回一个新的组件。在这个新的组件中的状态共享,经过 props 传给原来的组件。以刚刚那个 Toggle 组件为例子,高阶组件一样能够被屡次复用,经常能够配合装饰器一块儿使用。缓存

高阶组件的实现和 render props 也不太同样,主要是一个高阶函数。性能优化

2.3 render props 和高阶组件的弊端

无论是 render props 仍是高阶组件,他们要作的都是实现状态逻辑的复用,可这俩是完美的解决方案吗?考虑一下,若是咱们依赖了多个须要复用的状态逻辑的时候,该怎么写呢?以 render props 为例:微信

看看这个代码,你有没有一种似曾相识的感受?这一天,咱们终于想起被“回调地狱”支配的恐惧。不得再也不次祭出这张图了。

一样地,高阶组件也会有这个问题,但因为装饰器的简洁性,没有 render props 看起来那么可怕。除此以外,他们俩还有另外一个问题,那就是组件嵌套过深以后,会给调试带来很大的麻烦。这个是 render props 中组件嵌套在 React 开发者工具中的表现。

对于高阶组件来讲,若是你没有对组件手动设置 name/displayName,就会遇到更严重的问题,那就是一个个匿名组件嵌套。毕竟上面 render props 的嵌套至少能知道组件名。

社区里面也已经有不少解决 render props 嵌套的方案,其中 Epitath 提供了一种以 generator 的方法来解决嵌套问题,利用 generator 实现了伪同步代码。

更多细节能够参考黄子毅的这篇文章:精读《Epitath 源码 - renderProps 新用法》

2.4 React Hooks

React Hooks 则能够完美解决上面的嵌套问题,它拥有下面这几个特性。

  1. 多个状态不会产生嵌套,写法仍是平铺的

  2. 容许函数组件使用 state 和部分生命周期

  3. 更容易将组件的 UI 与状态分离


上面是一个结合了 useState 和 useEffect 两个 hook 方法的例子,主要是在 resize 事件触发时获取到当前的 window.innerWidth。这个 useWindowWidth 方法能够拿来在多个地方使用。经常使用的 Hook 方法以下:

3. useState & useRef

useState 是 React Hooks 中很基本的一个 API,它的用法主要有这几种:

  1. useState 接收一个初始值,返回一个数组,数组里面分别是当前值和修改这个值的方法(相似 state 和 setState)。
  2. useState 接收一个函数,返回一个数组。
  3. setCount 能够接收新值,也能够接收一个返回新值的函数。
const [ count1, setCount1 ] = useState(0);const [ count2, setCount2 ] = useState(() => 0);setCount1(1); // 修改 state

3.1 和 class state 的区别

虽然函数组件也有了 state,可是 function state 和 class state 仍是有一些差别:

  1. function state 的粒度更细,class state 过于无脑。
  2. function state 保存的是快照,class state 保存的是最新值。
  3. 引用类型的状况下,class state 不须要传入新的引用,而 function state 必须保证是个新的引用。

3.2 快照(闭包) vs 最新值(引用)

在开始前,先抛出这么一个问题。在 1s 内频繁点击10次按钮,下面代码的执行表现是什么?

若是是这段代码呢?它又会是什么表现?

若是你能成功答对,那么恭喜你,你已经掌握了 useState 的用法。在第一个例子中,连续点击十次,页面上的数字会从0增加到10。而第二个例子中,连续点击十次,页面上的数字只会从0增加到1。

这个是为何呢?其实这主要是引用和闭包的区别。

class 组件里面能够经过 this.state 引用到 count,因此每次 setTimeout 的时候都能经过引用拿到上一次的最新 count,因此点击多少次最后就加了多少。

在 function component 里面每次更新都是从新执行当前函数,也就是说 setTimeout 里面读取到的 count 是经过闭包获取的,而这个 count 实际上只是初始值,并非上次执行完成后的最新值,因此最后只加了1次。

3.3 快照和引用的转换

若是我想让函数组件也是从0加到10,那么该怎么来解决呢?聪明的你必定会想到,若是模仿类组件里面的 this.state,咱们用一个引用来保存 count 不就行了吗?没错,这样是能够解决,只是这个引用该怎么写呢?我在 state 里面设置一个对象好很差?就像下面这样:

const [state, setState] = useState({ count: 0 })

答案是不行,由于即便 state 是个对象,但每次更新的时候,要传一个新的引用进去,这样的引用依然是没有意义。

setState({ count: state.count + 1})

3.3 useRef

想要解决这个问题,那就涉及到另外一个新的 Hook 方法 —— useRef。useRef 是一个对象,它拥有一个 current 属性,而且无论函数组件执行多少次,而 useRef 返回的对象永远都是原来那一个。

useRef 有下面这几个特色:

  1. useRef 是一个只能用于函数组件的方法。
  2. useRef 是除字符串 ref、函数 refcreateRef 以外的第四种获取 ref 的方法。
  3. useRef 在渲染周期内永远不会变,所以能够用来引用某些数据。
  4. 修改 ref.current 不会引起组件从新渲染。

useRef vs createRef:

  1. 二者都是获取 ref 的方式,都有一个 current 属性。
  2. useRef 只能用于函数组件,createRef 能够用在类组件中。
  3. useRef 在每次从新渲染后都保持不变,而 createRef 每次都会发生变化。

3.4 写需求遇到的坑

以前在写需求的时候遇到过这样的一个坑。bankIdref 都是从接口获取到的,这里很天然就想到在 useCallback 里面指定依赖。

可是呢,这个 handlerReappear 方法须要在第一次进入页面的时候,向 JS Bridge 注册的事件,这就致使了一个问题,无论后来 handlerReappear 如何变化,registerHandler 里面依赖的 callback 都是第一次的,这也是闭包致使的问题。固然,你可能会说,我在 useEffect 里面也指定了依赖很差吗?但要注意这是个注册事件,意味着每次我都要清除上一次的事件,须要调用到 JS Bridge,在性能上确定不是个好办法。

最终,我选择使用 useRef 来保存 bankIdref,这样就能够经过引用来获取到最新的值。

3.5 Vue3 Composition API

在 vue3 里面提供了新的 Composition API,以前知乎有个问题是 React Hooks 是否能够改成用相似 Vue 3 Composition API 的方式实现?

而后我写了一篇文章,利用 Object.defineProperty 简单实现了 Composition API,能够参考:用 React Hooks 简单实现 Vue3 Composition API

固然这个实现还有不少问题,也比较简单,能够参考工业聚写的完整实现:react-use-setup

4. useEffect

useEffect 是一个 Effect Hook,经常使用于一些反作用的操做,在必定程度上能够充当 componentDidMountcomponentDidUpdatecomponentWillUnmount 这三个生命周期。useEffect 是很是重要的一个方法,能够说是 React Hooks 的灵魂,它用法主要有这么几种:

  1. useEffect 接收两个参数,分别是要执行的回调函数、依赖数组。
  2. 若是依赖数组为空数组,那么回调函数会在第一次渲染结束后( componentDidMount)执行,返回的函数会在组件卸载时( componentWillUnmount)执行。
  3. 若是不传依赖数组,那么回调函数会在每一次渲染结束后( componentDidMountcomponentDidUpdate)执行。
  4. 若是依赖数组不为空数组,那么回调函数会在依赖值每次更新渲染结束后(componentDidUpdate)执行,这个依赖值通常是 state 或者 props。

useEffect 比较重要,它主要有这几个做用:

  1. 代替部分生命周期,如 componentDidMount、componentDidUpdate、componentWillUnmount。
  2. 更加 reactive,相似 mobx 的 reaction 和 vue 的 watch。
  3. 从命令式变成声明式,不须要再关注应该在哪一步作某些操做,只须要关注依赖数据。
  4. 经过 useEffect 和 useState 能够编写一系列自定义的 Hook。

4.1 useEffect vs useLayoutEffect

useLayoutEffect 也是一个 Hook 方法,从名字上看和 useEffect 差很少,他俩用法也比较像。在90%的场景下咱们都会用 useEffect,然而在某些场景下却不得不用 useLayoutEffect。useEffect 和 useLayoutEffect 的区别是:

  1. useEffect 不会 block 浏览器渲染,而 useLayoutEffect 会。
  2. useEffect 会在浏览器渲染结束后执行,useLayoutEffect 则是在 DOM 更新完成后,浏览器绘制以前执行。

这两句话该怎么来理解呢?咱们以一个移动的方块为例子:

在 useEffect 里面会让这个方块日后移动 600px 距离,能够看到这个方块在移动过程当中会闪一下。但若是换成了 useLayoutEffect 呢?会发现方块不会再闪动,而是直接出如今了 600px 的位置。

缘由是 useEffect 是在浏览器绘制以后执行的,因此方块一开始就在最左边,因而咱们看到了方块移动的动画。然而 useLayoutEffect 是在绘制以前执行的,会阻塞页面的绘制,因此页面会在 useLayoutEffect 里面的代码执行结束后才去继续绘制,因而方块就直接出如今了右边。那么这里的代码是怎么实现的呢?以 preact 为例,useEffect 在 options.commit 阶段执行,而 useLayoutEffect 在 options.diffed 阶段执行。然而在实现 useEffect 的时候使用了 requestAnimationFramerequestAnimationFrame 能够控制 useEffect 里面的函数在浏览器重绘结束,下次绘制以前执行。

5. useMemo

useMemo 的用法相似 useEffect,经常用于缓存一些复杂计算的结果。useMemo 接收一个函数和依赖数组,当数组中依赖项变化的时候,这个函数就会执行,返回新的值。

const sum = useMemo(() => { // 一系列计算}, [count])

举个例子会更加清楚 useMemo 的使用场景,咱们就如下面这个 DatePicker 组件的计算为例:

DatePicker 组件每次打开或者切换月份的时候,都须要大量的计算来算出当前须要展现哪些日期。而后再将计算后的结果渲染到单元格里面,这里可使用 useMemo 来缓存,只有当传入的日期变化时才去计算。

6. useCallback

和 useMemo 相似,只不过 useCallback 是用来缓存函数。

6.1 匿名函数致使没必要要的渲染

在咱们编写 React 组件的时候,常常会用到事件处理函数,不少人都会简单粗暴的传一个箭头函数。

class App extends Component { render() { return <h1 onClick={() => {}}></h1> }}

这种箭头函数有个问题,那就是在每一次组件从新渲染的时候都会生成一个重复的匿名箭头函数,致使传给组件的参数发生了变化,对性能形成必定的损耗。

在函数组件里面,一样会有这个传递新的匿名函数的问题。从下面这个例子来看,每次点击 div,就会引发 Counter 组件从新渲染。此次更新明显和 Input 组件无关,但每次从新渲染以后,都会建立新的 onChange 方法。这样至关于传给 Input 的 onChange 参数变化,即便 Input 内部作过 shadowEqual 也没有意义了,都会跟着从新渲染。本来只想更新 count 值的,可 Input 组件 却作了没必要要的渲染。

这就是体现 useCallback 价值的地方了,咱们能够用 useCallback 指定依赖项。在无关更新以后,经过 useCallback 取的仍是上一次缓存起来的函数。所以,useCallback 经常配合 React.memo 来一块儿使用,用于进行性能优化。

7. useReducer && useContext

7.1 useReducer

useReducer 和 useState 的用法很类似,甚至在 preact 中,二者实现都是同样的。useReducer 接收一个 reducer 函数和初始 state,返回了 state 和 dispatch 函数,经常用于管理一些复杂的状态,适合 action 比较多的场景。

7.2 useContext

在上一节讲解 React16 新特性的时候,咱们讲过新版 Context API 的用法。

新版 Context 经常有一个提供数据的生产者(Provider),和一个消费数据的消费者(Consumer),咱们须要经过 Consumer 来以 render props 的形式获取到数据。若是从祖先组件传来了多个 Provider,那最终就又陷入了 render props 嵌套地狱。

useContext 容许咱们以扁平化的形式获取到 Context 数据。即便有多个祖先组件使用多个 Context.Provider 传值,咱们也能够扁平化获取到每个 Context 数据。

7.3 实现一个简单的 Redux

经过 useReducer 和 useContext,咱们彻底能够实现一个小型的 Redux。

reducer.js

Context.js

export const Context = createContext(null);

App.js

8. Custom Hooks

对于 react 来讲,在函数组件中使用 state 当然有一些价值,但最有价值的仍是能够编写通用 custom hooks 的能力。想像一下,一个单纯不依赖 UI 的业务逻辑 hook,咱们开箱即用。不只能够在不一样的项目中复用,甚至还能够跨平台使用,react、react native、react vr 等等。编写自定义 hook 也须要以 use 开头,这样保证能够配合 eslint 插件使用。在 custom hooks 中也能够调用其余 hook,当前的 hook 也能够被其余 hook 或者组件调用。以官网上这个获取好友状态的自定义 Hook 为例:

这个自定义 Hook 里面对好友的状态进行了监听,每次状态更新的时候都会去更新 isOnline,当组件卸载的时候会清除掉这个监听。这就是 React Hooks 最有用的地方,它容许咱们编写自定义 Hook,而后这个自定义 Hook 能够复用给多个组件,而且不会和 UI 耦合到一块儿。

9. React Hooks 原理

因为 preact hooks 的代码和原有的逻辑耦合度很小,这里为了更加浅显易懂,我选用了 preact hooks 的源码来解读。

9.1 Hooks 执行流程

在 React 中,组件返回的 JSX 元素也会被转换为虚拟 DOM,就是下方的 vnode,每一个 vnode 上面挂载了一个 _component 属性,这个属性指向了组件实例。而在组件实例上面又挂载了一个 _hooks 属性,这个 _hooks 属性里面保存了咱们执行一个组件的时候,里面全部 Hook 方法相关的信息。

首先,咱们有一个全局的 currentIndex 变量,当组件第一次渲染或者更新的时候,它会在每次进入一个函数组件的时候都重置为0,每次遇到一个 Hook 方法就会增长1,同时将这个 Hook 方法的信息放到 _list 里面。

当咱们下次进来或者进入下一个组件的时候, currentIndex 又会被置为0。

组件渲染 => currentIndex 重置 0 => 遇到 Hooks 方法,放进 _list => currentIndex++ => 渲染结束

组件更新 => currentIndex 重置 0 => 遇到 Hooks 方法,获取 _list[currentIndex]=> currentIndex++ => 重复上面步骤 => 更新结束

这个时候就会从刚才的 _list 里面根据 currentIndex 来取出对应项,因此咱们每次进来执行 useState,它依然能拿到上一次更新后的值,由于这里是缓存了起来。

经过上面的分析,你就不难发现,为何 hooks 方法不能放在条件语句里面了。由于每次进入这个函数的时候,都是要和 currentIndex 一一匹配的,若是更新先后少了一个 Hook 方法,那么就彻底对不上了,致使出现大问题。

9.2 useState 和 useReducer

这样你再来看下面 useState 和 useReducer 的源码就会更容易理解一些。

很明显,getHookState 是根据 currentIndex 来从 _list 里面取和当前 Hook 相关的一些信息。若是是初始化状态(即没有 hookState._component)这个属性的时候,就会去初始化 useState 的两个返回值,不然就会直接返回上一次缓存的结果。

9.3 useEffect

useEffect 和 useState 差很少,区别就在 useEffect 接收的函数会放到一个 _pendingEffects 里面,而非 _list 里面。

在 diff 结束以后会从 _pendingEffects 里面取出来函数一个个执行。afterPaint 里面使用了 requestAnimateFrame 这个方法,因此传给 useEffect 里面的方法是在浏览器绘制结束以后才会执行的。

9.4 总结

最后,这里对 React Hooks 的整个运行流程来进行一下总结和梳理。

  1. 每一个组件实例上挂载一个 _hooks 属性,保证了组件之间不会影响。
  2. 每当遇到一个 hooks 方法,就将其 push 到 currentComponent._hooks._list 中,且 currentIndex 加一。
  3. 每次渲染进入一个组件的时候,都会从将 currentIndex 重置为 0 。遇到 hooks 方法时,currentIndex 重复第二步。这样能够把 currentIndex 和 currentComponent._hooks._list 中的对应项匹配起来,直接取上次缓存的值。
  4. 函数组件每次从新执行后,useState 中还能保持上一次的值,就是来自于步骤3中的缓存。
  5. 因为依赖了 currentComponent 实例,因此 hooks 不能用于普通函数中。

10. React Hooks 实践

得益于 react hooks 将业务逻辑从 ui 中抽离出来,目前社区里面关于 react hooks 的实践,大都是从功能点出发。

从最简单的 api 封装,例如 useDebounce、useThrottle、useImmerState 等等,再到业务层面功能封装,比较出名的库有 react-use、umijs/hooks 等等。

举个栗子:umijs/hooks 的表格:

在后台管理系统开发中,表格是很是常见的场景,将分页、查询、loading、排序等等功能打包封装成通用 Hook,就能发挥很大的潜力。

11. 推荐阅读

  1. Umi Hooks - 助力拥抱 React Hooks
  2. 为何 React 如今要推行函数式组件,用 class 很差吗?
  3. useRequest- 蚂蚁中台标准请求 Hooks


本文分享自微信公众号 - 牧码的星星(gh_0d71d9e8b1c3)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。

相关文章
相关标签/搜索