React Hooks 温故而知新

1,365 阅读13分钟

React Hooks 是 React 16.8 新增的特性,它可以让你在不编写 class 的情况下使用 state 以及其它的 React 特性。

首先来看一下使用 Hooks 编写的代码是什么样子的:

import React, { useState, useEffect } from ‘react’;

export default () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = count;
  });

  return (
    <div>
      <p>Current count is: {count}</p>
      <button onClick={() => setCount(count + 1)}>increment count</button>
    </div>
  );
};

这段代码实现了一个简单的计数器功能,点击按钮使 count 增加,同时使页面标题的显示与 count 的变化同步。这里引入了 useStateuseEffect,我们稍后再来介绍他们,先来对比一下与 class component 的区别:

import React, { Component } from ‘react’;

export default class extends Component {
  state = {
    count: 0,
  };

  componentDidMount() {
    document.title = this.state.count;
  }

  componentDidUpdate() {
    document.title = this.state.count;
  }

  render() {
    return (
      <div>
        <p>Current count is: {this.state.count}</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          increment count
        </button>
      </div>
    );
  }
}

通过对比发现使用 Hooks 编写代码简练多了,state 初始化及变更 state 的方式发生了改变,生命周期函数也不用再编写了,Hooks 使我们拥有了在函数组件里使用 state 及生命周期的能力。React 的组件分类也进行了重新定义,以前是有状态组件、无状态组件,现在划分为了类组件及函数组件。

那么接下来就来详细分析一下 Hooks 的具体用法。

useState

首先是 useState,这个是干什么用的?useState 可以让我们在函数组件内部使用 state 进行状态管理,语法如下:

// state, 和 setState 可以任意命名,比如 count, setCount
const [state, setState] = useState(initialState)

useState 接收一个初始的状态 initialState,它返回一个数组,进行解构后一个是 state,一个是更新 state 的函数 setState,每次 setState 调用后,就会将组件的一次重新渲染加入队列(重新渲染组件或者和其它的 setState 进行批量更新)。

initialState 作为初始状态,可以是基本类型,也可以是对象(例如{a:1})或者函数,它只在组件首次渲染时被用到,首次渲染后 state 与 initialState 相同。

在每次重新渲染后,useState 都会返回最新的 state。而 setState 就相当于是类组件中的 this.setState(),但是 Hooks 里的 setState 并不会进行新 state 与旧 state 的合并,而是直接覆盖。

另外 useState() 接收的参数也是任意的,可以是基本类型,也可以是对象,还可以传入一个函数,在这个函数里可以拿到旧的 state,例如:

{/* <button onClick={() => setCount(count + 1)}>increment count</button> */}
<button onClick={() => setCount((oldCount) => oldCount + 1)}>
  increment count
</button>

useState 也可以使用多次,进行多个状态管理:

const [count, setCount] = useState(0);
const [color, setColor] = useState('red');

useEffect

那么 useEffect 又是干什么的呢?useEffect 可以让我们在函数组件中进行副作用操作,比如获取数据、DOM 操作、日志记录等都属于副作用。

useEffect 可以看做是 componentDidMount, componentDidUpdate, componentWillUnmount 这三个生命周期函数的组合。

例如在前面的例子中我们使用 useEffect 来更新页面标题:

useEffect(() => {
  document.title = count;
});

每次使用 setCount 更新状态,useEffect 里的函数都执行了一次,这就相当于是在类组件中同时使用了 componentDidMountcomponentDidUpdate

componentDidMount() {
  document.title = this.state.count;
}
componentDidUpdate() {
  document.title = this.state.count;
}

可以看到,在两个生命周期里写了同样的逻辑,这无疑造成了代码的冗余,而 useEffect 则会在每次渲染之后都执行,包括首次渲染,这样我们把类似于示例中的相同操作编写一次放在 useEffect 中即可。

对于上面示例中的副作用是无需清除的,但是还有些副作用是需要清除的,例如手动绑定的 DOM 事件。

在类组件中清除副作用可以在 componentWillUnmount 生命周期方法中进行,那么在 Hooks 里该如何实现呢?

其实,useEffect 已经提供了清除的机制,每个副作用都可以添加一个可选的返回函数,用来在组件卸载的时候进行清除操作,比如下面代码:

import React, { useState, useEffect } from ‘react’;

export default () => {
  const [size, setSize] = useState({ width: 0, height: 0 });

  const handleResize = () => {
    const win = document.documentElement.getBoundingClientRect();
    setSize({ width: win.width, height: win.height });
  };

  useEffect(() => {
    window.addEventListener('resize', handleResize);
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  });

  return (
    <div>
      <p>Current size is:</p>
      <p>
        width: {size.width}, height: {size.height}
      </p>
    </div>
  );
};

另外使用 useEffect 还有一个问题,就是每次进行 setState 操作时,可能都会触发副作用的执行, 即使更改的 state 和这个副作用并没有关系,比如下面的代码:

import React, { useState, useEffect } from ‘react’;

export default () => {
  const [count, setCount] = useState(0);
  const [color, setColor] = useState('red');

  useEffect(() => {
    console.log('count');
    document.title = count;
  });

  return (
    <div>
      <p style={{ color: color }}>Current count is: {count}</p>
      <button onClick={() => setCount(count + 1)}>increment count</button>
      <br />
      <button onClick={() => setColor(color === ‘red’ ? ‘blue’ : ‘red’)}>
        switch color
      </button>
    </div>
  );
};

我们发现在改变颜色时,修改页面标题的副作用也在反复执行,这无疑造成了资源的浪费,更不是我们想要的效果。当然,useEffect 给我们提供了第二个参数,可以对渲染进行控制,它接收一个数组作为参数,可以传入多个值,我们来对上面代码进行改进:

useEffect(() => {
  console.log('count');
  document.title = count;
}, [count]);

我们传入了 [count] 作为第二个参数,这样在每次渲染时都会先对 count 的新值和旧值进行对比,只有变化的时候这个副作用才会执行。

另外使用 Hooks 还需要遵循如下规则:

  • 只在函数组件中使用 Hooks,不要在普通函数中使用
  • 只在最顶层使用 Hooks,不要在条件、循环或者嵌套函数中使用 Hooks

当然为了保证这些规则,我们可以使用 ESLint 插件 eslint-plugin-react-hooks 进行约束。

useContext

首先看一下 Context 是怎么干什么用的:Context 提供了一种跨层级传递数据的方式,有了它就无需在每层组件都手动传递 props 了。

Context API 使用示例

通过下面例子回顾一下 Context API 的使用:

// 创建一个 Context 对象
const ThemeContext = React.createContext(‘light’);

const ContextAPIDemo = () => {
  return (
    // 每个 Context 对象都会返回一个 Provider React 组件,
    // 它接收一个 value 属性,传递给消费组件,
    // 允许消费组件订阅 context 的变化
    <ThemeContext.Provider value="dark">
      <MiddleComponent />
    </ThemeContext.Provider>
  );
};

// 一个中间组件,使用 Context 传递数据并不需要中间组件透传
const MiddleComponent = () => {
  return (
    <div>
      <ThemedButton />
    </div>
  );
};

class ThemedButton extends Component {
	// 把创建的 ThemeContext 对象赋值给 contextType 静态属性,
	// 这样就可以通过 this.context 访问到 ThemeContext 里面的数据
  static contextType = ThemeContext;
  render() {
    return <Button theme={this.context} />;
  }
}

const Button = (props) => {
  const btnBgColor = props.theme === ‘dark’ ? ‘#333’ : ‘white’;
  return <button style={{ backgroundColor: btnBgColor }}>Toggle Theme</button>;
};

这里使用 React.createContext() 创建了一个 ThemeContext,然后通过 Provider 把数据传递给消费组件,即 ThemedButton,最后在消费组件里把创建的 ThemeContext 赋值给了 contextType 静态属性,这样我们在消费组件里就可以通过 this.context 的方式访问数据了。

当然在消费组件里还可以使用 Consumer来获取 Context 的数据:

const ThemedButton = () => {
  return (
    <ThemeContext.Consumer>
      {(theme) => <Button theme={theme} />}
    </ThemeContext.Consumer>
  );
};

Ok,做了简单的回顾之后,就来看一下在 Hooks 里该如何使用 Context。

useContext 的使用

接下来我们使用 useContext 改造下上面的示例:

// 省略其它代码…
const Button = () => {
  const theme = useContext(ThemeContext);
  const btnBgColor = theme === ‘dark’ ? ‘#333’ : ‘white’;
  return <button style={{ backgroundColor: btnBgColor }}>Toggle Theme</button>;
};

改造后就完全可以不用 ConsumercontextType 静态属性了,哪里需要哪里就直接使用 useContext 即可。

useContext 接收一个 Context 对象作为参数,返回的是通过 Provider 传入的 value 数据。

由于示例中一直是一个固定的 ThemeContet,如果需要一个动态的 Context 该怎么办?我么可以通过 Providervalue 传入回调函数进行处理:

const ContextAPIDemo = () => {
  const [theme, setTheme] = useState(initialState.theme);

  const toggleTheme = (val) => {
    setTheme(val === 'dark' ? 'light' : 'dark');
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      <MiddleComponent />
    </ThemeContext.Provider>
  );
};

然后在目标组件可以通过 useContext 拿到回调函数,例如:

const Button = () => {
  const { theme, toggleTheme } = useContext(ThemeContext);
  const btnBgColor = theme === ‘dark’ ? ‘#333’ : ‘white’;
  return (
    <button
      style={{ backgroundColor: btnBgColor }}
      onClick={() => toggleTheme(theme)}
    >
      Toggle Theme
    </button>
  );
};

useReducer

提起 Reducer,用过 Redux 的同学应该都不陌生,Reducer Hook 里的 Reducer 其实跟 Redux 里的 Reducer 是同一个意思,接收旧的 state,返回新的 state,形如:(state, action) => newState

useReducer 的基本语法是这样的:

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

它接收三个参数:reducer, initialArg 及可选的 init函数:

  • reducer: 接收旧的 state,返回新的 state,形如:`(state, action) => newState
  • initialArg: 初始 state
  • init: 作为一个函数传入,惰性地初始化 state,state 将会被设置为 init(initialArg),这样可以将计算 state 的逻辑提取到外部,同时如果有重置 state 的需求的话也会很方便

下面就来看一下具体的用法:

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();
  }
}

const Counter = () => {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: ‘increment’ })}>+</button>
    </>
  );
};

useMemo

useMemo 的语法是这样的:

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

接收一个函数和依赖数组作为参数,然后只在依赖项变化时才会计算 memoizedValue 的值,基于这一点,我们可以作为性能优化的一个手段。

例如下面代码,我们通过对 computedNum 这个计算方法添加了 useMemo,把 count 作为依赖,只有 count 变化时 computedNum 才会计算,这样可以防止无关的操作也会引起函数的执行(如果不加,在点击 change color 时,computedNum也会反复计算)。

export default () => {
  const [count, setCount] = useState(0);
  const [color, setColor] = useState(‘blue’);

  const computedNum = useMemo(() => {
    console.log('render when count change');
    return count + 1;
  }, [count]);

  return (
    <div>
      <p>Current color is: {color}</p>
      <p>Current count is: {count}</p>
      <p>Computed num is: {computedNum}</p>
      <button onClick={() => setCount(count + 1)}>increment count</button>
      <button onClick={() => setColor(color === ‘blue’ ? ‘green’ : ‘blue’)}>
        change color
      </button>
    </div>
  );
};

当然,我们也可以用 useMemo 去包裹一个组件,从而实现类似 PureComponent 的效果,防止组件反复的渲染。

<>
  {useMemo(
    () => (
      <ColorDemo color={color} />
    ),
    [color]
  )}
<>

useCallback

useCallback 用法如下:

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

useCallback 返回了一个 memoized 回调函数,它跟 useMemo 类似,useCallback(fn, deps) 相当于 useMemo(() => fn, deps)。它的第二个参数跟 useMemo 的一样,传入一个依赖项数组,只有依赖变化时才会生成新的函数,否则一直是同一个函数,这在进行组件性能优化时非常有用,避免多余的开销。比如使用 props 传递函数时,使用 callback 可以避免每次渲染都生成一个新的函数,示例如下:

let firstOnClick;
const Button = (props) => {
  if (!firstOnClick) {
    firstOnClick = props.onClick;
  }

  console.log(firstOnClick === props.onClick);
  return <button onClick={props.onClick}>increment count</button>;
};

export default () => {
  const [count, setCount] = useState(0);
  const [color, setColor] = useState('blue');

  // 不用 useCallback 包裹的话,点击 change color 按钮,Button 组件里每次也都会生成新的函数
  // const handleIncrementCount = () => {
  //   setCount(count + 1);
  // };

  // 使用 useCallback 包裹后,只有点击 increment count 时 count 发生变化,才会生成新的函数,
  // 修改颜色并不会生成新函数
  const handleIncrementCount = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  return (
    <div>
      <p>Current color is: {color}</p>
      <p>Current count is: {count}</p>
      <Button onClick={handleIncrementCount} />
      <button onClick={() => setColor(color === 'blue' ? 'green' : 'blue')}>
        change color
      </button>
    </div>
  );
};

useRef

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

通常它被用来获取子组件(或 DOM 元素):

export default () => {
  const btnRef = useRef(null);

  useEffect(() => {
    btnRef.current.addEventListener('click', () => {
      alert('click me');
    });
  }, []);

  return (
    <div>
      <button ref={btnRef}>click</button>
    </div>
  );
};

另外,由于 useRef 在每次渲染时返回的都是同一个 ref 对象,因此可以用 ref.current 保存一个变量,它不会随着组件的重新渲染而受到影响,例如下面一个定时器的例子:

export default () => {
  const [count, setCount] = useState(0);
  const ref = useRef(0);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log('interval');
      setCount(++ref.current);
    }, 1000);

    return () => clearInterval(timer);
  }, []);

  return (
    <div>
      <p>Current count is: {count}</p>
    </div>
  );
};

同时,变更 .current 属性也不会引起组件的重新渲染。

useImperativeHandle

useImperativeHandle 可以让我们在使用 ref 时自定义暴露给父组件的实例值,这样可以隐藏掉一些私有方法或属性,下面是一个 useImperativeHandleforwardRef 配合使用的例子:

function Input(props, ref) {
  const [val, setVal] = useState(0);

  useEffect(() => {
    setVal(props.count);
  }, [props.count]);

  const clearInput = useCallback(() => {
    setVal('');
  }, []);

  useImperativeHandle(ref, () => ({
    clear: () => {
      clearInput();
    },
  }));

  return (
    <input type="text" value={val} onChange={(e) => setVal(e.target.value)} />
  );
}

const FancyInput = forwardRef(Input);

export default () => {
  const [count, setCount] = useState(0);
  const fancyRef = useRef(null);

  const handleClearInput = useCallback(() => {
    fancyRef.current.clear();
  }, []);

  return (
    <div>
      <p>Current count is: {count}</p>
      <button onClick={() => setCount(count + 1)}>increment count</button>
      <hr />
      <FancyInput ref={fancyRef} count={count} />
      <button onClick={handleClearInput}>clear input</button>
    </div>
  );
};

useLayoutEffect

useLayoutEffectuseEffect 用法相同,只不过 useLayoutEffect 会在所有 DOM 更新之后同步调用,可以使用它读取 DOM 布局并同步触发重渲染,但是还是建议使用 useEffect,以避免阻塞 UI 更新。

这里有个示例,使用useEffectuseLayoutEffect 会有明显的差异:

const useLayoutEffectDemo = () => {
  const [height, setHeight] = useState(100);
  const boxRef = useRef(null);

  useLayoutEffect(() => {
    if (boxRef.current.getBoundingClientRect().height < 200) {
      console.log('set height: ', height);
      setHeight(height + 10);
    }
  }, [height]);

  const style = {
    width: '200px',
    height: `${height}px`,
    backgroundColor: height < 200 ? 'red' : 'blue',
  };

  return (
    <div ref={boxRef} style={style}>
      useLayoutEffect Demo
    </div>
  );
};

我们相当于给盒子设置了粗糙的过渡变化,使用 useEffect 这种过渡是生效的,但是换成 useLayoutEffect 之后,过渡效果已经没了,只会显示最终的效果。也就是说在浏览器执行绘制之前,useLayoutEffect 内部的更新计划将被同步刷新。

useDebugValue

用于开发自定义 Hooks 调试使用,例如:

useDebugValue(
  size.width < 500 ? '---- size.width < 500' : '---- size.width > 500'
);

可以在 React DevTools 里查看相关的信息输出。

自定义 Hooks

React Hooks 的强大,不仅仅是因为官方的内置 Hooks,同时它还支持自定义 Hooks,提高了组件的复用性,从一定程度了取代了 HOC 和 render props 的复用方式。

自定义 Hook 需要以 use 开头,其内部还可以调用其他的 Hook,自定义 Hook 不需要具有特殊的标识,我们可以自定义参数及返回值,下面是一个自定义 Hook 示例,自定了一个 useSize,用来获取窗口的大小。

import { useState, useCallback, useEffect } from 'react';

const getDomElInfo = () => document.documentElement.getBoundingClientRect();

const getSize = () => ({
  width: getDomElInfo().width,
  height: getDomElInfo().height,
});

export default function useSize() {
  const [size, setSize] = useState(() => getSize());

  const onChange = useCallback(() => {
    setSize(() => getSize());
  }, []);

  useEffect(() => {
    window.addEventListener('resize', onChange, false);

    return () => {
      window.removeEventListener('resize', onChange, false);
    };
  }, [onChange]);

  return size;
}

使用这个自定义 Hook 也很简单:

import useSize from './useSize';

export default () => {
  const size = useSize();
  return (
    <div>
      <p>
        size: width-{size.width}, height-{size.height}
      </p>
    </div>
  );
};

好了,关于 React Hooks 就介绍到这里,它不仅仅打破了原有的组件编写方式,更是一种新的思维, 非常值得学习。

相关参考