React Hooks原理与实践

679 阅读9分钟

什么是React Hooks?

首先需要了解react组件的概念

react组件分为以下几种:

1、功能组件(无状态组件)

Functional (Stateless) Component,功能组件也叫无状态组件,一般只负责渲染。

function Welcome(props) {
    return <h1>Hello, {props.name}</h1>; 
}

2、类组件(有状态组件)
Class (Stateful) Component,类组件也是有状态组件,也可以叫容器组件。一般有交互逻辑和业务逻辑。

class Welcome extends React.Component {
  state = {
      name: ‘tori’,
  }
componentDidMount() {
      fetch(…);
      …
  }
render() {
  return (
      <>
          <h1>Hello, {this.state.name}</h1>
          <button onClick={() => this.setState({name: ‘007’})}>改名</button>
      </>
    );
}
}

3、渲染组件 Presentational Component,和功能(无状态)组件类似。

const Hello = (props) => {
  return (
    <div>
      <h1>Hello! {props.name}</h1>
    </div>
  )
}

总结

  • 函数组件一定是无状态组件,展示型组件一般是无状态组件;
  • 类组件既可以是有状态组件,又可以是无状态组件;
  • 容器型组件一般是有状态组件。
  • 划分的原则概括为:分而治之、高内聚、低耦合
  • 通过以上组件之间的组合能实现绝大部分需求。

Hook 出现之前,组件之间复用状态逻辑很难,解决方案(HOC、Render Props)都需要重新组织组件结构, 且代码难以理解。在React DevTools 中观察过 React 应用,你会发现由 providers,consumers,高阶组件,render props 等其他抽象层组成的组件会形成“嵌套地狱”。
组件维护越来越复杂,譬如事件监听逻辑要在不同的生命周期中绑定和解绑,复杂的页面componentDidMount包涵很多逻辑,代码阅读性变得很差。
class组件中的this难以理解,且class 不能很好的压缩,并且会使热重载出现不稳定的情况。更多引子介绍参见官方介绍。
所以hook就为解决这些问题而来:

  • 避免地狱式嵌套,可读性提高。
  • 函数式组件,比class更容易理解。
  • class组件生命周期太多太复杂,使函数组件存在状态。
  • 解决HOC和Render Props的缺点。
  • UI 和 逻辑更容易分离。

官方hooks API

1.useState

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

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

class this.setState更新是state是合并, useState中setState是替换。

useState 和 class state 的区别 虽然函数组件也有了 state,但是 function state 和 class state 还是有一些差异:

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

image.png

image.png

在第一个例子中,连续点击十次,页面上的数字会从0增长到10。而第二个例子中,连续点击十次,页面上的数字只会从0增长到1

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

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

2.useRef

要解决上面这个问题,就需要使用useRef,useRef是一个对象,他拥有一个current属性,并且不管函数组件执行多少次,useRef返回的对象永远都是原来的那一个

image.png

useRef 有下面这几个特点:

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

3.useEffect

useEffect 是一个 Effect Hook,常用于一些副作用的操作,在一定程度上可以充当 componentDidMountcomponentDidUpdatecomponentWillUnmount 这三个生命周期。useEffect 是非常重要的一个方法,可以说是 React Hooks 的灵魂,它用法主要有这么几种:

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

image.png

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

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

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

  1. useEffect 不会阻塞浏览器渲染,而 useLayoutEffect 会。
  2. useEffect 会在浏览器渲染结束后执行,useLayoutEffect 则是在 DOM 更新完成后,浏览器绘制之前执行。 例如我们使用useEffect方法来更新Demo的位置,那么在页面渲染时,我们就会看到Demo原来的位置,然后这个时候useEffect方法才会执行,更新Demo的位置,我们就会在页面上看到Demo位置的变化,但是有的时候我们不想让用户看到这个变化的过程,会比较丑,比如变更一个元素的位置,就会变成闪现过去,这个时候就需要使用useLayoutEffect

4.useContext

跨组件共享数据的钩子函数

const value = useContext(MyContext); 
// MyContext 为 context 对象(React.createContext 的返回值) 
// useContext 返回MyContext的返回值。 
// 当前的 context 值由上层组件中距离当前组件最近的<MyContext.Provider> 的 value prop 决定。

useContext 的组件总会在 context 值变化时重新渲染, 所以<MyContext.Provider>包裹的越多,层级越深,性能会造成影响。 <MyContext.Provider>的value 发生变化时候,包裹的组件无论是否订阅content value,所有组件都会重新渲染。

5.useReducer

const [state, dispatch] = useReducer(reducer, initialState);

reducer就是一个只能通过action将state从一个过程转换成另一个过程的纯函数;

useReducer就是一种通过(state,action) => newState的过程,和redux工作方式一样。 数据流: dispatch(action) => reducer更新state => 返回更新后的state

官方推荐以下场景需要useReducer更佳:

  • state 逻辑较复杂且包含多个子值, 可以集中处理。
  • 下一个 state 依赖于之前的 state。
  • 想更稳定的构建自动化测试用例。
  • 想深层级修改子组件的一些状态,使用 useReducer 还能给那些会触发深更新的组件做性能优化,因为你可以向子组件传递 dispatch 而不是回调函数 。 使用reducer有助于将读取与写入分开。
举个例子

useState版login 我们先看看登录页常规的使用useState的实现方式:

function LoginPage() {
        const [name, setName] = useState(''); // 用户名
        const [pwd, setPwd] = useState(''); // 密码
        const [isLoading, setIsLoading] = useState(false); // 是否展示loading,发送请求中
        const [error, setError] = useState(''); // 错误信息
        const [isLoggedIn, setIsLoggedIn] = useState(false); // 是否登录

        const login = (event) => {
            event.preventDefault();
            setError('');
            setIsLoading(true);
            login({ name, pwd })
                .then(() => {
                    setIsLoggedIn(true);
                    setIsLoading(false);
                })
                .catch((error) => {
                    // 登录失败: 显示错误信息、清空输入框用户名、密码、清除loading标识
                    setError(error.message);
                    setName('');
                    setPwd('');
                    setIsLoading(false);
                });
        }
        return ( 
            //  返回页面JSX Element
        )
    }

接下来使用useReducer改造上面的login demo:

const initState = {
        name: '',
        pwd: '',
        isLoading: false,
        error: '',
        isLoggedIn: false,
    }
    function loginReducer(state, action) {
        switch(action.type) {
            case 'login':
                return {
                    ...state,
                    isLoading: true,
                    error: '',
                }
            case 'success':
                return {
                    ...state,
                    isLoggedIn: true,
                    isLoading: false,
                }
            case 'error':
                return {
                    ...state,
                    error: action.payload.error,
                    name: '',
                    pwd: '',
                    isLoading: false,
                }
            default: 
                return state;
        }
    }
    function LoginPage() {
        const [state, dispatch] = useReducer(loginReducer, initState);
        const { name, pwd, isLoading, error, isLoggedIn } = state;
        const login = (event) => {
            event.preventDefault();
            dispatch({ type: 'login' });
            login({ name, pwd })
                .then(() => {
                    dispatch({ type: 'success' });
                })
                .catch((error) => {
                    dispatch({
                        type: 'error'
                        payload: { error: error.message }
                    });
                });
        }
        return ( 
            //  返回页面JSX Element
        )
    }

6.useMemo

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

const sum = useMemo(() => {
    // 一系列计算
}, [count])
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);; 返回一个 memoized 值,和useCallback一样,当依赖项发生变化,才会重新计算 memoized 的值。useMemo和useCallback不同之处是:它允许你将memoized应用于任何值类型(不仅仅是函数)。

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

  • useMemo会在render前执行
  • 如果没有提供依赖项数组,useMemo 在每次渲染时都会计算新的值。
  • useMemo用于返回memoize,防止每次render时大计算量带来的开销。
  • 使用useMemo优化需谨慎,因为优化本身也带来了计算,大多数时候,你不需要考虑去优化不必要的重新渲染

7.useCallback

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

const memoizedCallback = useCallback( () => { doSomething(a, b); }, [a, b], );
//返回一个 memoized 回调函数。

总结: useCallback将返回一个记忆的回调版本,仅在其中一个依赖项已更改时才更改。当将回调传递给依赖于引用相等性的优化子组件以防止不必要的渲染时,此方法很有用。使用回调函数作为参数传递,每次render函数都会变化,也会导致子组件rerender, useCallback可以优化rerender。