React 之 Hooks 入门

246 阅读14分钟

前言

HookReact v16.8 新增特性。让函数组件也拥有状态。使我们具有不编写类组件也可以使用 state 和其他的 React 特性的能力

为什么要使用 Hook

类组件的缺点

类组件带来了生命周期特性stateprops 等特性的同时,也带来了一些问题

  • 状态逻辑难复用: 在组件之间复用状态很难,可能要用到 render props渲染属性)或者 HOC高阶组件),但无论是渲染属性,还是高阶组件,都会在原先的组件外包裹一层父容器(一般都是 div 元素),导致层级冗余

  • 趋向复杂难以维护:生命周期中混杂不相干的逻辑(如:在 componentDidMount 中注册事件以及其他的逻辑、componentWillUnmount 中卸载事件,这样分散不集中的写法,很容易写出 bug )。类组件中到处都是对状态的访问和处理,导致组件难以拆分成更小的组件

  • this 指向问题: 父组件给子组件传递函数时,必须绑定 this

常用的 Hook

useState

import React, { useState } from 'react'

const Example1 = () => {
    // 声明⼀个叫 "count" 的 state 变量 
    const [count, setCount] = useState(0)

    return (
        <div>
            <p>You clicked {count} times</p>
            <button onClick={() => setCount(count + 1)}>
                Click me
            </button>
        </div>
    )
}

默认值

const [ state, setState ] = useState(initialValue)
const Example2 = props => {
    const defaultCount = props.count || 0
    const [ count, setCount ] = useState(defaultCount) 
    
    return (
        <div>
            点击次数: { count }
            <button onClick={() => setCount(count + 1)}>点我</button>
        </div>
    )
}

虽然 defaultCount 只有在第一次渲染的时候才会⽤到,但是计算的逻辑会在组件的每次渲染中都会运行,如果复杂度过⾼的话,会很浪费资源

惰性初始 state

useState 支持我们在调⽤的时候直接传⼊一个值,来指定 state 的默认值。还⽀持我们传入⼀个函数,在函数中计算并返回初始的 state,此函数只在初始渲染时被调用

const Example2 = props => {
    const [ count, setCount ] = useState(() => props.count || 0 })
    return (
        <div>
            <span>点击次数: { count }</span>
            <button onClick={() => setCount(count + 1)}>点我</button>
        </div>
    )
}

组件每渲染一次,useState 中的函数就会执⾏⼀遍、浪费性能吗?我们可以做个测试

const Example3 = props => {
   const [ count, setCount ] = useState(() => {
       console.log('useState default value function is call')
       return props.count || 0 
   })

   return ( 
       <div>
           <span>点击次数: { count }</span>
           <button onClick={() => setCount(count + 1)}>点我</button>
       </div>
   )
}

可以看到,useState 中的 console 只被执行一次,即函数只会执行⼀次

获取上一轮的值

在使用 useState 的第二个参数时,如果我们想要获取上⼀轮该 state 的值,只需要在 useState 第⼆个参数传入函数形式。也就是上面的例子中 setCount 使⽤时,传⼊⼀个函数

// 该函数的参数就是上一轮的 `state` 的值
setCount(count => count + 1)

注意

1、你可能想知道: 为什么叫 useState ⽽不叫 createState

使用 "create" 可能不是很准确。因为 state 只在组件首次渲染的时被创建,在下一次渲染时,都是更新 stateuseState 只返回当前的 state,否则它就不是 "state" 了!这也是 Hook 名字总以 use 开头的一个原因

2、⽅括号有什么用?

const [count, setCount] = useState(0)

这种语法叫数组解构。它意味着我们同时创建了 countsetCount 两个变量,count 的值为 useState 返回的第一个值,setCount 是返回的第二个值

它等价于以下代码:

var countStateVariable = useState(0) // 返回⼀个有两个元素的数组 
var count = countStateVariable[0] // 数组⾥的第⼀个值
var setCount = countStateVariable[1] // 数组⾥的第⼆个值

当我们使⽤ useState 定义 state 变量时候,它返回一个有两个值的数组。第⼀个值是当前的 state ,第二个值是更新 state 的函数

3、我们并没有传递 this 给 React,是怎么知道 useState 对应的是哪个组件?

React 保持对当前渲染中的组件的追踪。多亏了 React Hook 规范,我们得知 Hook 只会在 React 组件中被调用(或⾃定义 Hook —— 同样只会在 React 组件中被调用)

4、多个 useState 的情况,React 是如何识别哪个是哪个呢

其实很简单,它是靠第一次执行的顺序来记录的。每个组件存放 useState 是以链表的形式,每使用⼀个新的 useState ,就向链表末尾添加⼀个 useState。 这就是多个 useState 调用会得到各自独立的本地 state 的原因

只在最顶层使用 Hook不可以嵌套在任何循环、if 判断或函数中。因为它导致我们的 state 变得可选,这会破坏 ReactuseState 链表顺序,导致我们的 state 拿不到对应的正确的值

5、不像 Class 中的 this.setState ,更新 state 变量总是替换它而不是合并它

const Box = () => {
    const [state, setState] = useState({ 
        top: 0, 
        left: 0, 
        width: 100, 
        height: 100
    })

    // ...
    useEffect(() => {
        handleWindowMouseMove = e => {
            // 展开 「...state」 以确保我们没有 「丢失」 width 和 height
            setState(state => ({ 
                ...state, 
                left: e.pageX, 
                top: e.pageY
            })
        )}

        // 注意:这是个简化版的实现 
        window.addEventListener('mousemove', handleWindowMouseMove)

        return () =>window.removeEventListener('mousemove', handleWindowMouseMove)
        )
    }, [])
}

6、我该使用单个还是多个 state 变量?

把多个 state 都放在同一个 useState 调用中,或是每一个字段都对应一个 useState 调用,这两⽅式都行得通。你需要在这两个极端之间找到平衡,然后把相关 state 组合到几个独立的 state 变量时,组件会更加的可读

如果 state 的逻辑开始变得复杂,我们推荐用 reducer 来管理它,或使用⾃定义 Hook 进行包装

useEffect

Effect Hook 中可以执行任何在函数组件中带来副作用的操作,⽐如⽹络请求,监听事件,查找 DOM

Effect Hook 告诉 React完成对 DOM 的更改后运行你的“副作用”函数。可以看做 React 类组件的⽣命周期函数中 componentDidMountcomponentDidUpdatecomponentWillUnmount 这三个生命周期函数的组合

import React, { useState, useEffect } from 'react' 

const Example4 = () => {
    const [count, setCount] = useState(0)
    
    // Similar to componentDidMount and componentDidUpdate:
    useEffect(() => {
        // Update the document title using the browser API
        document.title = `You clicked ${count} times`
    })

    return ( 
        <div>
            <p>You clicked {count} times</p>
            <button onClick={() => setCount(count + 1)}>
                Click me
            </button>
        </div>
    )
}

第二个参数

有以下三种情况:

1、什么都不传,组件每次 render 之后 useEffect 都会调用

2、传⼊一个空数组 [],则只会调用一次,之后它永远都不会重复执行

3、传⼊一个数组,其中包括变量,只有这些变量变动时,useEffect 才会执⾏

如果某些特定值在两次渲染之间没有发生变化,可以通知 React 跳过对 Effect Hook 的调用,只要传递数组作为 useEffect 的第二个可选参数即可

// 仅在 count 更改时更新
useEffect(() => {
    document.title = `You clicked ${count} times`
}, [count]) 

如果想 Effect Hook 执行只运⾏一次 (仅在组件挂载和卸载时执行),可以传递一个空数组([])作为第二个参数。这就告诉 React 你的 Effect 不依赖于 propsstate 中的任何值,所以它永远都不需要重复执行

// 仅在第一次渲染时执行
useEffect(() => {
    document.title = `You clicked ${count} times`
}, []) 

清除effect

通常,组件卸载时需要清除 Effect 创建的诸如订阅或计时器 ID 等资源。要实现这一点,useEffect 函数需返回⼀个清除函数。作用于清除上一次副作用遗留下来的状态,如果该 Effect 只调用一次,该回调函数相当于 componentWillUnmount ⽣命周期。以下就是一个创建订阅的例子

useEffect(() => {
    const subscription = props.source.subscribe()

    return () => {
        subscription.unsubscribe()
    }
}, [props.source])

为防⽌内存泄漏,清除函数会在组件卸载前执⾏。另外,如果组件多次渲染(通常如此),则在执行下⼀个 Effect 之前,上一个 Effect 会被清除

注意

1、Effect Hook 会在每次渲染后都执行吗?

默认情况下,它在第一次渲染之后和每次更新之后都会执行(但是,可以添加依赖数组来控制是否执行)

你可能会更容易接受 Effect Hook 发⽣在 DOM 渲染之后这种概念,不用再去考虑挂载还是更新React 保证了每次运行 Effect Hook 的同时,DOM 都已经更新完毕

2、如果 Effect Hook 的依赖频繁变化,我该怎么办?

const Example5 = () => {
    const [count, setCount] = useState(0)

    useEffect(() => {
        const id = setInterval(() => {
            setCount(count + 1); // 这个 Effect 依赖于 `count` state 
        }, 1000)

        return () => clearInterval(id) 
    }, [count])

    return <h1>{count}</h1> 
}

指定 count 作为依赖会导致每次改变发生时定时器都被重置。事实上,每个 setInterval 在被清除前(类似于 setTimeout)都会调用一次。但这并不是我们想要的。要解决这个问题,我们可以使⽤ setState 的函数式更新形式。它允许我们指定 state 该如何改变而不用引用当前 state:

const Example6 = () => {
    const [count, setCount] = useState(0)

    useEffect(() => {
        const id = setInterval(() => {
            setCount(c => c + 1) // 在这不依赖于外部的 `count` 变量 
        }, 1000)

        return () => clearInterval(id)
    }, []) //   我们的 Effect 不使⽤组件作⽤域中的任何变量

    return <h1>{count}</h1> 
}

useContext

上下文

const value = useContext(MyContext)

接收一个 context 对象(React.createContext 的返回值)并返回该 context 当前值。当前 context 值由上层组件中距离当前组件最近的 <MyContext.Provider>value prop 决定

// 创建一个 context
const Context = createContext(0)

三种写法

// 组件一, Consumer 写法
class Item1 extends PureComponent {
    render () { 
        return (
            <Context.Consumer>
              {(count) => (<div>{count}</div>) }
            </Context.Consumer>
        )
    }
}

缺点:嵌套复杂,Consumer 第⼀个⼦节点必须为函数,⽆形增加了工作量

// 组件⼆, contextType 写法
class Item2 extends PureComponent {
    static contextType = Context render () {
        const count = this.context 
        
        return (
            <div>{count}</div> 
        )
    }
}

缺点:只⽀持类组件,⽆法在多 context 的情况下使⽤

// 组件三, useContext 写法 
const Item3 = () => {
    const count = useContext(Context); 
    return (
        <div>{ count }</div> 
    )
}

const Example7 = () => {
    const [count, setCount] = useState(0)
   
    return (
        <div>
            点击次数: { count }
            <button onClick={() => setCount(count + 1)}>点我</button> 
            <Context.Provider value={count}>
                <Item1 />
                <Item2 />
                <Item3 />
            </Context.Provider>
        </div>
    )
}

缺点:不需要嵌套,多 context 写法简单

当组件上层最近的 <MyContext.Provider> 更新时,该 Hook 会触发重渲染,并使用最新传递给 provider contextvalue 值。即使祖先使用 React.memoshouldComponentUpdate,也会在组件本身使用 useContext 时重新渲染

注意

1、别忘记 useContext 的参数必须是 context 对象本身

2、useContext(MyContext) 相当于 Class 组件中的 static contextType = MyContext 或者 <MyContext.Consumer>

useContext(MyContext) 只是让你能够读取 context 的值以及订阅 context 的变化。你仍然需要在上层组件树中使⽤用 <MyContext.Provider> 来为下层组件提供上下文来源

useReducer

useState 的替代方案。它接收一个形如 (state, action) => newStatereducer, 并返回当前的 state 以及与其配套的 dispatch 方法

const [state, dispatch] = useReducer(reducer, initialArg, init) 

在某些场景下,useReducer 会比 useState 更适⽤,例如 state 逻辑较复杂且包含多个子值,或者下⼀个 state 依赖于之前的 state 等。并且,使用 useReducer 还能给那些会触发深更新的组件做性能优化,因为你可以向⼦组件传递 dispatch ⽽不是回调函数

const initialState = 0;
const reducer = (state, action) => {
  switch (action) {
    case 'increment': return state + 1;
    case 'decrement': return state - 1;
    case 'reset': return 0;
    default: throw new Error('Unexpected action');
  }
};

首先,我们定义了初始化的 initialState 以及 reducer。注意这里的 state 仅是一个数字,不是对象。熟悉 Redux 的开发者可能是困惑的,但在 hook 中是适宜的。此外,action 仅是一个普通的字符串。

下面是一个使用 useReducer 的组件

const Example01 = () => {
  const [count, dispatch] = useReducer(reducer, initialState);
  return (
    <div>
      {count}
      <button onClick={() => dispatch('increment')}>+1</button>
      <button onClick={() => dispatch('decrement')}>-1</button>
      <button onClick={() => dispatch('reset')}>reset</button>
    </div>
  );
};

useMemo

useMemo 不会影响逻辑,只是做性能优化。 useEffect 是副作⽤,是在渲染之后完成的,而useMemo 是在渲染期间完成的

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b])
const Example10 = () => {
    const [ count, setCount ] = useState(0) 
    const add = useMemo(() => {
        return count + 1 
    }, [count]) 
    
    return (
        <div>
            <span>点击次数: { count }<br/>次数加⼀: { add }</span>
            <button onClick={() => { setCount(count + 1) }}>点我</button>
        </div>
    )
}

第⼆个参数

useMemo 也⽀持传入第二个参数,用法和 useEffect 类似

1、不传数组,每次重渲染都会重新计算

2、空数组,只会组件第一次挂载时计算⼀次,后续重渲染均不再计算

3、依赖对应的值,当对应的值发生变化时,才会重新计算(可以依赖另外一个 useMemo 返回的值)

4、把“创建”函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized 值。这种优化有助于避免在每次渲染时都进⾏⾼开销的计算

注意

1、记住,传⼊ useMemo 的函数在渲染期间执行

因此,请不要在这个函数内部执行与渲染⽆关的操作,诸如副作用这类的操作属于 useEffect 的适用范畴,⽽不是 useMemo

2、可以把 useMemo 作为性能优化的⼿段,但不要把它当成语义上的保证

将来,React 可能会选择“遗忘”以前的⼀些 memoized 值,并在下次渲染时重新计算它们

useCallback

useCallback 可以理解为 useMemo 的语法糖

const memoizedCallback = useCallback(() => {
    doSomething(a, b)
}, [a, b])

针对⼦组件渲染优化的问题,尤其是当向子组件传递函数 props 时,每次 render 都会创建新函数,导致⼦组件不必要的渲染,浪费性能,这个时候,就是 useCallback 的用武之地

const Counter = memo(function Counter(props) { 
    console.log("counter render")
    return (
        <div>
            Counter: <h1 onClick={props.onClick}>{props.count}</h1>
        </div>
    ) 
})
const Example11 = () => {
    const [count, setCount] = useState(0) 
    const double = useMemo(() => {
        return count * 2 },[count === 3])
        const onClick = () => { console.log("click")
    }

    return ( 
        <div>
            <button type="button" onClick={() => setCount(count + 1)}>
                Click{count},double:{double}
            </button>
            <Counter count={double} onClick={onClick}/>
      </div>
    )
}

注意了:在这里是 count = 3 的期间 double 会重新计算,也就是当 count 变为 3 , double 为 6时,重新计算,得到 count 为 4,double 为 8的结果,以后,double 就不会重新计算了。count 变化的时候,⽗组件重新渲染,导致 onClick 句柄也发⽣了变化,就导致组件也发生重新渲染了, 但是实际上 onClick 句柄不应该发生变化 使⽤ useMemo 或者 useCallback 锁定 onClick 句柄

const onClick = useMemo(() => {
    return () => {
        console.log("click")
    }
},[]) // 表示只有在第⼀次执⾏
 
const onClick = useCallback(() => { 
    return () => {
        console.log("click")
    }
},[])

把内联回调函数及依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新。当你把回调函数传递给经过优化的子组件,它将⾮常有⽤

经过优化的子组件:使用引用相等性去避免⾮必要渲染的子组件(例如使用 shouldComponentUpdate 的子组件)

useRef

useRef 有什么作⽤呢,总共有两种⽤法

1、获取子组件的实例

2、在函数组件中的⼀个全局变量,不会因为重复 render 重复申明, 类似于类组件的 this.xxx const refContainer = useRef(initialValue)

useRef 返回⼀个可变的 Ref 对象,其 current 属性被初始化为传入的参数 (initialValue)。返回的 Ref 对象在组件的整个⽣命周期内保持不变

const TextInputWithFocusButton = () => { 
    const inputEl = useRef(null)
    const onButtonClick = () => {
        // `current` 指向已挂载到 DOM 上的⽂文本输⼊入元素
        inputEl.current.focus()
    }

    return (
        <>
            <input ref={inputEl} type="text" />
            <button onClick={onButtonClick}>Focus the input</button>
        </>
    )
}

本质上,useRef 就像是可以在其 current 属性中保存⼀个可变值的“盒子”。 你应该熟悉 Ref 这⼀种访问 DOM 的主要⽅式。如果你将 Ref 对象以 <div ref={myRef} /> 形式传入组件,则⽆论该节点如何改变,React 都会将 Ref 对象的 current 属性设 置为相应的 DOM 节点

然而,useRefref 属性更有用。它可以很方便地保存任何可变值,其类似于在 class 中使用实例字段的⽅式。这是因为它创建的是一个普通 Javascript 对象。而 useRef 和⾃建⼀个 {current: ...} 对象的唯一区别是,useRef 会在每次渲染时返回同⼀个 Ref 对象

请记住,当 Ref 对象内容发⽣变化时,useRef 并不会通知你。变更 current 属性不会引发组件重新渲染

Ref Hook 不仅可以⽤于 DOM Refs。Ref 对象是⼀个 current 属性可变,且可以容纳任意值的通用容器,类似于 Class 的实例属性

const Example12 = () => {
    const [ count, setCount ] = useState(0) const timer = useRef(null)
    let timer2

    useEffect(() => {
        let id = setInterval(() => {
            setCount(count + 1)
          }, 1000)
        timer.current = id timer2 = id
        return () => {
            clearInterval(timer.current)
        }
    }, [count])

    const onClickRef = useCallback(() => { clearInterval(timer.current) }, [])
    const onClick = useCallback(() => { clearInterval(timer2)}, [timer2])
    return (
        <div>
            点击次数: { count }
            <button onClick={onClick}>普通</button>
            <button onClick={onClickRef}>useRef</button>
        </div>
    )
}

当我们使⽤普通的按钮去暂停定时器时,发现定时器⽆法清除,因为组件每次 render,都会重新申明一次 timer2, 定时器的 ID 在第二次 render 时,就丢失了,所以⽆法清除定时器。针对这种情况,就需要使⽤到 useRef,来保留定时器 ID,类似于类组件中 this.xxx,这就是 useRef 的另外⼀种⽤法

useImperativeHandle

forwardRef

React.forwardRef 字面意思理解为转发 ref,它会创建一个 React 组件,这个组件能够将其接受的 ref 属性转发到其组件树下的另一个组件中

useImperativeHandle(ref, createHandle, [deps])

useImperativeHandle 可以让你在使用 ref 时⾃定义子组件暴露给⽗组件的实例值。在⼤多数情况下,应当避免使用 ref 这样的命令式代码。useImperativeHandle 应与 forwardRef ⼀起使用:

import { forwardRef, useRef, useImperativeHandle } from 'react';

const ExposeFocusInput = forwardRef((props, ref) => {
  const realInputRef = useRef(null);
  useImperativeHandle(ref, () => ({
    // 曝光 focus 
    focus:() => realInputRef.current.focus();
  }));
  return <input {...props} ref={realInputRef} />;
});

const Form: React.FC = () => {
  const inputRef = useRef(null);

  const handleClick = () => {
    inputRef.current.focus();
  }

  return (
    <>
      <ExposeFocusInput ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

在本例中,⽗组件的inputRef.current中会有获取ExposeFocusInput组件中inputfocus()方法

⾃定义 Hook

⾃定义 Hook 是⼀个函数,其名称常以 "use" 开头,函数内部可以调⽤其他的 Hook。 自定义 Hook 是⼀种⾃然遵循 Hook 设计的约定,并不是 React 的特性

import React, { useState, useRef, useEffect } from 'react'

const useCounter = (count) => { 
    return (
        <div>
            Counter: <h1>{count}</h1>
        </div>
    )
}

const useCount = (defaultCount) => {
    const [count, setCount] = useState(0) 
    const it = useRef()

    useEffect(() => {
        it.current = setInterval(() => {
            setCount(count => count+1)
        }, 1000)
    }, [])

    useEffect(() => {
        if (count >= 10) {
            clearInterval(it.current)
          }
    }, [count])
    
    return [count, setCount] 
}

const Example13 = props => {
    const [count, setCount] = useCount(0) 
    const Counter = useCounter(count)
    return ( 
        <div>
            <button type="button" onClick={()=>{ setCount(count+1) }}>
              Click---{count}
            </button>
            {Counter}
        </div>
    )
}

注意

1、自定义 Hook 必须以 "use" 开头吗?

必须如此。这个约定⾮常重要。不遵循的话,由于⽆法判断某个函数是否包含对其内部 Hook 的调用,React 将无法⾃动检查你的 Hook 是否违反了 Hook 的规则

2、在两个组件中使用相同的 Hook 会共享 state 吗?

不会。⾃定义 Hook 是⼀种重⽤状态逻辑的机制(例如设置为订阅并存储当前值),所以每次使用⾃定义 Hook 时,其中的所有 state副作⽤都是完全隔离的。