React 的 Hooks 已经出来有一段时间了,不过正式发布是在最近的 16.8 的版本。

因为 Hooks 是完全向后兼容的,就像官方文档所说的,没有任何破坏性的更改:

  • Completely opt-in. You can try Hooks in a few components without rewriting any existing code. But you don’t have to learn or use Hooks right now if you don’t want to.
  • 100% backwards-compatible. Hooks don’t contain any breaking changes.

所以你并不需要马上重构之前的组件。那既然这样,为什么我们还要使用它呢?

如果你是个有经验的 React 使用者,应该很清楚 React 组件的包装地狱和高阶组件的嵌套问题。React 官方文档在这一点上解释的很清楚了,我也不再重复。接下来,我们通过几个简单的代码示例来了解下常用的 Hooks API,希望对你快速上手 React Hooks 会有一点帮助。

经典示例

初步上手,先来一个经典示例——Counter:

import React, { useState } from 'react';

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

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

对,就是这么简单!只是,state 的用法和 class 组件有点区别。在组件方法内没有 this,所以也就没有 this.statethis.setState,state 的初始值,用 useState 的第一个,也是唯一一个参数来初始化。然后 useState 返回一个数组:成对的 state 和更新 state 的方法(我们可以叫它 setState,但你可以随意声明)。因为没有 class 组件一样的生命周期,在这里,通过 setCount 更新 count 后,组件重新渲染,接着在渲染后通过 useState 来获取更新后的数据。

因为现在的组件并不是纯渲染的无状态组件(stateless components)了,我们可以更好的称之为函数组件(function components)。

多个 state

只使用一个 state 是无法满足日常需求的,我们把上面的 Counter 示例再增加一个 state。

export default function HooksExample() {
  const [incrementCount, setIncrementCount] = useState(0);
  const [decrementCount, setDecrementCount] = useState(100);

  return (
    <div className={style.wrapper}>
      <p>Increment Count: {incrementCount}</p>
      <p>Decrement Count: {decrementCount}</p>
      <button onClick={() => setIncrementCount(incrementCount + 1)}>
        Increment
      </button>
      <button onClick={() => setDecrementCount(decrementCount - 1)}>
        Decrement
      </button>
    </div>
  );
}

当更新其中一个 state 时,两个 state 的值可以互不影响。但你必须得注意它们的声明顺序。可能你会好奇,useState 和 state 又是怎么关联起来的,毕竟 useState 只有一个初始值的参数而已。

根据经验,我猜测模块内部有一个指针,类似数组的索引,当 Hook 调用时用来确定该 state 是属于哪个 Hook 的。当组件重新渲染,重置模块指针。官方文档的答案解释了更多细节,你可以继续深入了解。

所以,在声明 Hooks 时,顺序很重要,而且 Hooks 不能在循环、条件语句和嵌套方法中声明,为此,React 特别说明了一些声明 Hooks 时的规则。为了减少错误,官方还单独开发了 ESLint 插件:eslint-plugin-react-hooks

合并 state

当一个组件内的 state 太多时,可能会比较混乱。所以,你可以像 class 组件那样把 state 初始化为一个对象。

const [state, setState] = useState({ count: 0, name: 'Nicolas' });

不过,在 state 更新时并不会像 class 组件的 this.setState 那样自动合并对象。好在 Hooks 的 setState 还可以通过传递 function 的方式更新数据,然后用 ES6 的对象扩展语法,让代码更加简练。

setState(prevState => {
  return {
    ...prevState,
    count: prevState.count + 1
  };
});

副作用

之前,在手动更改 DOM,或者数据请求等操作时,同一逻辑功能的代码可能会分散到不同的生命周期方法内,这样很容易出现 bug。Hooks 的 useEffect 可以让这些代码集中到独立的 Effect 中,以便更好的维护。

export default function HooksExample() {
  const [id, setId] = useState(0);
  const [name, setName] = useState('----');

  useEffect(() => {
    console.log(`fetch name with user ID: ${id}`);
    fetchName(id).then(name => setName(name));
  });

  return (
    <div className={style.wrapper}>
      <p>Name: {name}</p>
      <button onClick={() => setId(id + 1)}>
        Update User ID
      </button>
    </div>
  );
}

上面的示例代码中,数据请求 API fetchName,在第一次渲染和 userId 更新后重新渲染时,都会调用执行。在之前 class 组件中,需要在 componentDidMountcomponentDidUpdate 中编写相似的重复逻辑。当然,我们也可以把重复逻辑提取到一个方法中,但不管怎么样,这个方法的调用还是需要在不同的地方编写。

不过上面的代码有一个问题,如果你打开 Chrome 控制台,你会发现,在每次更新 id 时,useEffect 中的 console.log 都会打印 2 次。

那是因为,每一个 state 的更新都会导致组件的重新渲染。当更新 id 后,重新渲染并调用Effect,在 fetchName 的请求响应后,又更新了 name,然后组件再次更新,当第二次调用 Effect 时,id 并没有变化,但 fetchName 又请求了一次。

这么显而易见的问题,React 团队早就考虑到了。如果你给 useEffect 传递第 2 个参数,在组件重新渲染时,Hooks 会根据它来判断是否需要跳过此次调用,判断依据是该值在组件渲染前后的变化。你可能会想到之前在 componentDidUpdate 中有相似的逻辑。

componentDidUpdate(prevProps, prevState) {
  if (prevState.id !== this.state.id) {
    fetchName(id)
      .then(name => {
        // ...
      });
  }
}

而 Hooks,你只需要给 useEffect 的第 2 个参数传递一个数组,Hooks 会依次判断每一个数组项在组件渲染前后是否有变化。

useEffect(() => {
  console.log(`fetch name with user ID: ${id}`);
  fetchName(id).then(name => setName(name));
}, [id]);

然而,还有一种常见情况。如果你的组件只在渲染时执行一次 Effect,那么你只需要给第 2 个参数传递一个空数组 [] 即可。

另外,在 class 组件中通常会在 componentWillUnmount 中做一些清理工作,现在使用 Hooks 会变得格外简单。

假设,我们在 useEffect 中要执行一个 timer 方法,该方法接受一个回调函数的参数,当 timer 执行后,每隔一秒就会自动调用这个回调。同时 timer 初始化时会返回一个 clear 方法。该方法用来停止 timerclearTimeout)。

如果你为 Effect 返回一个 function,Hooks 会在每次渲染前(调用 Effect 时)自动执行清理工作。但在这里,我们只需要在组件卸载前清理下,我们只需要传入 [] 即可。

useEffect(() => {
  const clear = timer(() => setCount(count => count + 1));
  return () => clear();
}, []);

封装 Hooks

假如你有多个组件,在这些组件中,有相同的处理状态的逻辑。之前,我们可以封装到高阶组件或者 Render Props 来解决。那么在 Hooks 中,你只需要把这些逻辑封装成自己的 Hook,以供多个组件重用。

function useTimer(initialTime = 0) {
  const [count, setCount] = useState(initialTime);

  useEffect(() => {
    const clear = timer(() => setCount(count => count + 1));
    return () => clear();
  }, []);

  return count;
}

function HooksExample() {
  const time = useTimer(0);

  return (
    <div className={style.wrapper}>
      <p>Time: {time}</p>
    </div>
  );
}

但不管怎么封装,你始终应该把你的 Hook 定义为带 use 前缀的方法: useSomething

还有很多细节,你可以到官方文档中去了解。

state 管理

前面在合并 state 一节中有讲到把一个 state 声明为对象来组织多个状态的方法。然而,React Hooks 为我们提供了更好的方案:

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    // ...
  }
}

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

使用 useReducer 可以很好的维护组件的多个状态,熟悉 Redux 的同学应该能很好的理解。如果你不希望在项目中引入 Redux 而让项目变得复杂,又想使用 reducer 来简单管理多个状态的话,useReducer 是个很好的选择。

React Hooks 还有其他一些有趣的 API,不过常用的应该就是上面这些了,大家有兴趣可以通过React 官方文档继续了解。

最后

很明显,React Hooks 的出现是为了解决之前 React 在开发时的一些痛点,也是为了更好的把一些逻辑代码封装到组件中,让组件真正抽象。

但就像文章开头提到的,不使用 Hooks 并不会影响你现有的代码,你也不用着急动手马上重构。因为,Hooks 的思想和已经存在多年的组件生命周期可能有些不同,你还需要慢慢适应。但如果你已经准备好了,那么,赶紧在一个新的组件中开始尝试 React Hooks 吧!