Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。
Hook 解决了以下问题
使用 Hook 从组件中提取状态逻辑,使得这些逻辑可以单独测试并复用。Hook 使你在无需修改组件结构的情况下复用状态逻辑。 这使得在组件间或社区内共享 Hook 变得更便捷。
Hook 将组件中相互关联的部分拆分成更小的函数(比如设置订阅或请求数据),而并非强制按照生命周期划分。你还可以使用 reducer 来管理组件的内部状态,使其更加可预测。
this
的工作方式,与其他语言存在巨大差异。Hook 使你在非 class 的情况下可以使用更多的 React 特性,它拥抱了函数,同时没有牺牲 React 的精神原则。无需学习复杂的函数式或响应式编程技术。
为了让大家快速了解 hook,以下内容涵盖了大部分功能应用场景。
更多更详细的用法请阅读官方文档。
以下是一段 useState
的示例,一个计数器组件:
import React, { useState } from 'react'; function Example() { // 声明一个叫 "count" 的 state 变量 const [count, setCount] = useState(0); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> ); } 复制代码
它的等价 class 示例为:
class Example extends React.Component { constructor(props) { super(props); this.state = { count: 0 }; } render() { return ( <div> <p>You clicked {this.state.count} times</p> <button onClick={() => this.setState({ count: this.state.count + 1 })}> Click me </button> </div> ); } } 复制代码
useState
是一种新方法,它与 class 里面的 this.state
提供的功能完全相同。useState
可以使用数字或字符串对其进行赋值,并不一定是对象。保存多个状态可以多次调用 useState
。useState
返回当前 state 以及更新 state 的函数,使用数组解构的方式定义这两个变量。useState
返回的更新 state 的函数的标识是稳定的,并且不会在组件重新渲染时发生变化。Effect Hook 可以让你在函数组件中执行副作用操作(数据获取,设置订阅以及手动更改 React 组件中的 DOM 都属于副作用)
import React, { useState, useEffect } from 'react'; function Example() { 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> ); } 复制代码
我们为计数器增加了一个小功能:将 document 的 title 设置为包含了点击次数的消息。
useEffect
用于定义每次 React 更新 DOM 之后执行的副作用。useEffect
接收数组作为第二个参数,通过对比数组中的参数发生改变来决定是否执行传给 useEffect
的函数。不传入第二个参数则每次渲染后都执行,传入空数组则代表只在第一次渲染后执行。useEffect
接收的函数可以返回一个函数用于清除副作用。(例如,取消订阅,清理计时器)useEffect
执行前会先清除上一次渲染的副作用。useEffect
实际上涵盖了 class 组件的绝大部分生命周期,详情查看 useEffect 与class 组件生命周期映射关系 一节。
const value = useContext(MyContext); 复制代码
useContext
接收一个 context 对象(React.createContext
的返回值)并返回该 context 的当前值。useContext
所在的组件在 provider 的值发生变化时会重新渲染,即便祖先元素使用了 React.memo
或 shouldComponentUpdate
。useContext
仅仅增加了一种使用 Context 的方式,功能上并无区别。useState
的替代方案。
const initialState = {count: 0}; function reducer(state, action) { switch (action.type) { case 'increment': return {count: state.count + 1}; case 'decrement': return {count: state.count - 1}; default: throw new Error(); } } function Counter() { const [state, dispatch] = useReducer(reducer, initialState); return ( <> Count: {state.count} <button onClick={() => dispatch({type: 'decrement'})}>-</button> <button onClick={() => dispatch({type: 'increment'})}>+</button> </> ); } 复制代码
useReducer
和 Redux 很像,接收 reducer 函数、初始值、初始化函数并返回一个完整的 state 和 dispatch 函数。useReducer
适用于管理逻辑复杂且包含多个子值的 state,或者下一个 state 依赖之前的 state 等。useReducer
能给触发深更新的组件做性能优化。(向子组件传递 dispatch 而不是回调函数)dispatch
函数的标识是稳定的,并且不会在组件重新渲染时发生变化。这两个有相似关联之处,放在一起。
const memoizedCallback = useCallback( () => { doSomething(a, b); }, [a, b], ); 复制代码
useCallback
接收内联回调函数和依赖项数组作为参数,它返回该函数的 memoized 版本,回调函数仅在某个依赖项改变时才会更新。useCallback
返回的函数传递给使用 shouldComponentUpdate
或 React.memo
的子组件时可以避免非必要的渲染。useCallback(fn, deps)
相当于 useMemo(() => fn, deps)
。const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]); 复制代码
useMemo
接收 ”计算“ 函数和依赖项数组作为参数,它返回一个 memoized 值,仅在某个依赖项改变时才会重新计算 memoized 值。useMemo
返回的值传递给使用 shouldComponentUpdate
或 React.memo
的子组件时可以避免非必要的渲染。useMemo
有助于避免在每次渲染时都进行高开销的计算。这意味着如果是很简单的计算请谨慎考虑是否使用。useRef
返回一个可变的 ref 对象,其 .current
属性被初始化为传入的参数。返回的 ref 对象在组件的整个生命周期内保持不变。
本质上,useRef
就像是可以在其 .current
属性中保存一个可变值的 “盒子”。
function 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> </> ); } 复制代码
这是一种我们比较熟悉的 ref 的使用方式,用于直接访问真实 DOM。不过以前我们更多的使用字符串 ref。然而 useRef
不止于此,它比 ref 属性更有用。详情查看 useRef 用例 一节。
请移步官方文档:Hook 规则
实质上这是个错误的标题,仅为了更好的理解 Hook。在行为上等效,并没有实质关系。
useEffect
真正实现了让相关联的逻辑都在一处的想法,我们可以在 useEffect
中设置定时器,在返回的清理函数中清除定时器。不必像 class 组件一样将这些本应该在一起的逻辑分散在各个生命周期中。除此之外,相同的抽象逻辑可以被抽离出来在不同的函数组件内复用。
以下是生命周期对照表:
class 组件生命周期 | useEffect 示例代码 |
---|---|
componentDidMount | useEffect(() => { // effect here }, []) |
componentDidMount & componentDidUpdate | useEffect(() => { // effect here }) |
componentDidUpdate | useEffect(() => { if (firstRef.current) return; // effect here }) |
componentWillUnMount | useEffect(() => () => { // clear effect here }, []) |
这是我们最熟悉的使用方式。
function 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> </> ); } 复制代码
function LifeCycleExample() { const firstMountRef = useRef(true); useEffect(() => { if (firstMountRef.current) { firstMountRef.current = false; } else { // effect here } }) return (<p>LifeCycleExample</p>); } 复制代码
如果频繁使用,则可以包装成自定义 Hook:
function useUpdateEffect(effect) { const firstMountRef = useRef(true); useEffect(() => { if (firstMountRef.current) { firstMountRef.current = false; } else { effect(); } }); } function LifeCycleExample() { useUpdateEffect(() => { // effect here }) return (<p>LifeCycleExample</p>); } 复制代码
function Counter() { const [count, setCount] = useState(0); const prevCountRef = useRef(); useEffect(() => { prevCountRef.current = count; }); const prevCount = prevCountRef.current; return <h1>Now: {count}, before: {prevCount}</h1>; } 复制代码
如果频繁使用,则可以包装成自定义 Hook:
function usePrevious(value) { const ref = useRef(); useEffect(() => { ref.current = value; }); return ref.current; } function Counter() { const [count, setCount] = useState(0); const prevCount = usePrevious(count); return <h1>Now: {count}, before: {prevCount}</h1>; } 复制代码
function Timer() { const intervalRef = useRef(); useEffect(() => { intervalRef.current = setInterval(() => { // ... }); }); return ( <> <button onClick={() => clearInterval(intervalRef.current)}>停止</button> </> ); } 复制代码
以上只为举例,并不局限于这几种使用方式。
自定义 Hook 是一种自然遵循 Hook 设计的约定,而并不是 React 的特性。自定义 Hook 必须使用 use 开头的方式命名。通过自定义 Hook,可以将组件逻辑提取到可重用的函数中。实际上在上一节 useRef 用例 中已经使用了自定义 Hook。
自定义 Hook 必须以 “use” 开头吗?必须如此。这个约定非常重要。不遵循的话,由于无法判断某个函数是否包含对其内部 Hook 的调用,React 将无法自动检查你的 Hook 是否违反了 Hook 规则。
这个不多说,不需要自己实现,官方直接提供。
这一层在工程层面实现,我们使用 immutable 库将官方提供的基础 Hook 包装一层,便于使用。immutable 并不是为 Hook 专门准备的,在 class 组件中我们也可以用类似的库对状态进行包装。但是 Hook 这种可以将状态逻辑和组件分离的能力,提供了更好的封装的可能性。这一层将会很优雅,不会增加开发中的理解难度。
通常我们需要这样更新深层次的状态:
setState((oldValue) => ({ ...oldValue, foo: { ...oldValue.foo, bar: { ...oldValue.foo.bar, alice: newAlice }, }, })); 复制代码
封装后,直接修改状态由 immer 保证数据不可变性:
const [state, setState] = useImmerState({foo: {bar: 1}}); setState(s => s.foo.bar++); 复制代码
const [state, dispatch] = useImmerReducer( (state, action) => { case 'ADD': state.foo.bar += action.payload; case 'SUBTRACT': state.foo.bar -= action.payload; default: return; }, {foo: {bar: 1}} ); dispatch('ADD', {payload: 2}); 复制代码
这个很好理解,比如将 Array
、Map
、Set
等复杂数据结构封装为 hook。
这里使用一个 typescript 的接口定义来体现:
const [list, methods, setList] = useArray([]); interface ArrayMethods<T> { push(item: T): void; unshift(item: T): void; pop(): void; shift(): void; slice(start?: number, end?: number): void; splice(index: number, count: number, ...items: T[]): void; remove(item: T): void; removeAt(index: number): void; insertAt(index: number, item: T): void; concat(item: T | T[]): void; replace(from: T, to: T): void; replaceAll(from: T, to: T): void; replaceAt(index: number, item: T): void; filter(predicate: (item: T, index: number) => boolean): void; union(array: T[]): void; intersect(array: T[]): void; difference(array: T[]): void; reverse(): void; sort(compare?: (x: T, y: T) => number): void; clear(): void; } 复制代码
在有了基本的数据结构后,可以对场景进行封装,如 useVirtualList 就是一个价值非常大的场景的封装。需要注意的是,场景的封装不应与组件库耦合,它应当是业务与组件之间的桥梁,不同的组件库使用相同的 Hook 实现不同的界面,这才是一个理想的模式。
业务中比较常用的场景 Hooks:
上一节中 Hook 分层设计 已经从某种程度上解决了一部分 Hook 使用的粒度问题。这里简单补充一下:
如果仅仅将 class 中的 state 平移过来当做一整个状态,那分离状态,将状态复用的好处将完全得不到体现。不相关的状态堆砌在一起,不仅完全无法复用,还会隐藏其中通用的状态。再者,如果每个 state 都被单独拆分出来,在一次触发好几个状态变更时,我们需要分别对其进行更新。代码变的难以理解,增加维护难度。
我们应从 Hook 的动机入手,实现关注点分离,将关联的逻辑和状态放在一起。以能够拆分成自定义的 Hook 达到复用的目的来设计 Hook 的状态粒度。
其实在上面对各项 Hook 做介绍时,我们已经提到了几种优化方式。在此处做一下总结。
跟 class 组件类似,性能优化的思路都是通过以下两个方面入手:
const MyComponent = React.memo(function MyComponent(props) { /* 使用 props 渲染 */ }); 复制代码
React.memo
实际上和 Hook 关系不大,它是针对函数组件的一种性能优化方式。它于 React.PureComponent
非常相似,但只适用于函数组件。默认情况下 React.memo
只对 props 和 prevProps 做浅层比较,但我们可以通过传入第二个参数来控制比较过程。
function MyComponent(props) { /* 使用 props 渲染 */ } function areEqual(prevProps, nextProps) { /* 如果把 nextProps 传入 render 方法的返回结果与 将 prevProps 传入 render 方法的返回结果一致则返回 true, 否则返回 false */ } export default React.memo(MyComponent, areEqual); 复制代码
可以将 areEqual
理解为 React.Component
中由开发者自己控制的 shouldComponentUpdate
。
当使用 React.memo
或 shouldComponentUpdate
来决定是否进行重新渲染时,则强烈依赖外部传入的 props 的稳定性。由于函数组件每次渲染都会执行一次,其内部定义的回调函数每次都是新的。这些回调函数传递给子组件时,即便使用 React.memo
或 shouldComponentUpdate
也无法实现期望的效果。
const Child = React.memo(function (props) { return (<button onClick={props.onClick}>点击</button>) }) function Parent() { // 通过 useCallback 进行记忆 callback,并将记忆的 callback 传递给 Child const handleClick = useCallback(() => { console.log('clicked'); }, /** deps = */ []) return ( <> <p>Parent</p> <Child onClick={handleClick} /> </> ); } 复制代码
通过使用 useCallback
,在依赖项没有发生变化时,React 会确保 handleClick
函数的标识是稳定的,并且不会在组件重新渲染时发生变化。这样 Child
接收到的 props 在 React.memo
对比中则没有变化,Child
不会触发重新渲染,达到性能优化的目的。
const Child = React.memo(function ({ button }) { return (<button>{button.text}</button>) }) function Parent() { const button = React.useMemo(() => { // 此处有高开销的计算过程 return { text: '保存', // ... } }, []) return ( <> <p>Parent</p> <Child button={button} /> </> ); } 复制代码
和 useCallback
相似,为了避免重新渲染,我们可以使用 useMemo
记忆计算过的值。当依赖项没有发生变化时,高开销的计算过程将会被跳过,useMemo
将返回相同的值(如果是引用值,则是相同的引用标识)。这样 Child
接收到的 props 在 React.memo
对比中则没有变化,Child
不会触发重新渲染,达到性能优化的目的。
注意事项:使用 useMemo
在每次渲染时都会有函数重新定义的过程,计算量如果很小的计算函数,也可以选择不使用 useMemo
,因为这点优化并不会作为性能瓶颈的要点,反而可能使用错误还会引起一些性能问题。