Hooks 最佳实践

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

Hook 是一些可以让你在函数组件里“钩入” React state 及生命周期等特性的函数。

Hook 使你在无需修改组件结构的情况下复用状态逻辑。 这使得在组件间或社区内共享 Hook 变得更便捷。

Hook 将组件中相互关联的部分拆分成更小的函数(比如设置订阅或请求数据),而并非强制按照生命周期划分。你还可以使用 reducer 来管理组件的内部状态,使其更加可预测。

什么时候我会用 Hook? 如果你在编写函数组件并意识到需要向其添加一些 state,以前的做法是必须将其转化为 class。现在你可以在现有的函数组件中使用 Hook。

一、Hooks 底层原理

React 是如何把对 Hook 的调用和组件联系起来的?

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

每个组件内部都有一个「记忆单元格」列表。它们只不过是我们用来存储一些数据的 JavaScript 对象。

当你用 useState() 调用一个 Hook 的时候,它会读取当前的单元格(或在首次渲染时将其初始化),然后把指针移动到下一个。

这就是多个 useState() 调用会得到各自独立的本地 state 的原因。

二、Hooks 规范

1. 只在最顶层使用 Hook

不要在循环,条件或嵌套函数中调用 Hook ,确保总是在你的 React 函数的最顶层以及任何 return 之前调用他们。遵守这条规则,你就能确保 Hook 在每一次渲染中都按照同样的顺序被调用。

示例:

function Form() {
  // 1. 使用`name state`变量
  const [name, setName] = useState('Mary');

  // 2. 使用 `effect` 来持久化表单
  useEffect(function persistForm() {
    localStorage.setItem('formData', name);
  });

  // 3. 使用 `surname` 状态变量
  const [surname, setSurname] = useState('Poppins');

  // 4. 使用 `effect` 来更新标题
  useEffect(function updateTitle() {
    document.title = name + ' ' + surname;
  });

  // ...
}

那么 React 怎么知道哪个 state 对应哪个 useState?答案是 React 靠的是 Hook 调用的顺序。因为示例中,Hook 的调用顺序在每次渲染中都是相同的,所以它能够正常工作:

// ------------
// 首次渲染
// ------------
useState('Mary')           // 1. 使用 'Mary' 初始化变量名为 name 的 state
useEffect(persistForm)     // 2. 添加 effect 以保存 form 操作
useState('Poppins')        // 3. 使用 'Poppins' 初始化变量名为 surname 的 state
useEffect(updateTitle)     // 4. 添加 effect 以更新标题

// -------------
// 二次渲染
// -------------
useState('Mary')           // 1. 读取变量名为 name 的 state(参数被忽略)
useEffect(persistForm)     // 2. 替换保存 form 的 effect
useState('Poppins')        // 3. 读取变量名为 surname 的 state(参数被忽略)
useEffect(updateTitle)     // 4. 替换更新标题的 effect

// ...

只要 Hook 的调用顺序在多次渲染之间保持一致,React 就能正确地将内部 state 和对应的 Hook 进行关联。

如果将一个 Hook 调用放到一个条件语句中会发生什么呢?

(例如 persistForm effect)

// 🔴 在条件语句中使用 Hook 违反第一条规则
  if (name !== '') {
    useEffect(function persistForm() {
      localStorage.setItem('formData', name);
    });
  }

在第一次渲染中 name !== ’’ 这个条件值为 true,所以我们会执行这个 Hook。但是下一次渲染时我们可能清空了表单,表达式值变为 false。此时的渲染会跳过该 Hook,Hook 的调用顺序发生了改变:

useState('Mary')           // 1. 读取变量名为 name 的 state(参数被忽略)
// useEffect(persistForm)  // 🔴 此 Hook 被忽略!
useState('Poppins')        // 🔴 2 (之前为 3)。读取变量名为 surname 的 state 失败
useEffect(updateTitle)     // 🔴 3 (之前为 4)。替换更新标题的 effect 失败

React 不知道第二个 useState 的 Hook 应该返回什么。React 会以为在该组件中第二个 Hook 的调用像上次的渲染一样,对应的是 persistForm 的 effect,但并非如此。从这里开始,后面的 Hook 调用都被提前执行,导致 bug 的产生。

这就是为什么 Hook 需要在我们组件的最顶层调用。如果我们想要有条件地执行一个 effect,可以将判断放到 Hook 的内部:
useEffect(function persistForm() {
    // 👍 将条件判断放置在 effect 中
    if (name !== '') {
      localStorage.setItem('formData', name);
    }
  });

2. 只在 React 函数中调用 Hook

不要在普通的 JavaScript 函数中调用 Hook。你可以:

✅ 在 React 的函数组件中调用 Hook
✅ 在自定义 Hook 中调用其他 Hook (我们将会在下一页 中学习这个。)

遵循此规则,确保组件的状态逻辑在代码中清晰可见。

三、自定义 Hook

自定义 Hook 是一种自然遵循 Hook 设计的约定,而并不是 React 的特性。

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

通过自定义 Hook,可以将组件逻辑提取到可重用的函数中。

这是一个聊天程序中的组件FriendListItem ,该组件用于显示好友的在线状态:

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

function FriendListItem(props) {
----------------------------------------------------
  const [isOnline, setIsOnline] = useState(null);
  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });
----------------------------------------------------
  return (
    <li style={{ color: isOnline ? 'green' : 'black' }}>
      {props.friend.name}
    </li>
  );
}

1. 提取自定义 Hook

当想在两个函数之间共享逻辑时,把它提取到第三个函数中。而组件和 Hook 都是函数,所以也同样适用这种方式。

自定义 Hook 是一个函数,其名称以 use 开头,函数内部可以调用其他的 Hook。

例如,下面的 useFriendStatus 是一个自定义的 Hook:

import { useState, useEffect } from 'react';

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
    };
  });

  return isOnline;
}

此处并未包含任何新的内容——逻辑是从上述组件拷贝来的(记得去掉props)。与组件中一致,请确保只在自定义 Hook 的顶层无条件地调用其他 Hook。

2. 使用自定义 Hook

把这个逻辑提取到 FriendListItem 的自定义 Hook 中,然后就可以使用它了:

function FriendListItem(props) {
------------------------------------------
  const isOnline = useFriendStatus(props.friend.id);
------------------------------------------
  return (
    <li style={{ color: isOnline ? 'green' : 'black' }}>
      {props.friend.name}
    </li>
  );
}

3. 在多个 Hook 之间传递信息

由于 Hook 本身就是函数,因此可以在它们之间传递信息。

这是一个聊天消息接收者的选择器,它会显示当前选定的好友是否在线:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

const friendList = [
  { id: 1, name: 'Phoebe' },
  { id: 2, name: 'Rachel' },
  { id: 3, name: 'Ross' },
];

function ChatRecipientPicker() {
---------------------------------------------------------
//当前选择的好友 ID 保存在 recipientID 状态变量中,并在用户从 <select> 中选择其他好友时更新这个 state。
  const [recipientID, setRecipientID] = useState(1); 
//由于 useState 为我们提供了 recipientID 状态变量的最新值,
//因此我们可以将它作为参数传递给自定义的 useFriendStatus Hook:
  const isRecipientOnline = useFriendStatus(recipientID);
---------------------------------------------------------
  return (
    <>
---------------------------------------------------------
//当我们选择不同的好友并更新 recipientID 状态变量时,
//useFriendStatus Hook 将会取消订阅之前选中的好友,并订阅新选中的好友状态。
      <Circle color={isRecipientOnline ? 'green' : 'red'} />
---------------------------------------------------------
      <select
        value={recipientID}
        onChange={e => setRecipientID(Number(e.target.value))}
      >
        {friendList.map(friend => (
          <option key={friend.id} value={friend.id}>
            {friend.name}
          </option>
        ))}
      </select>
    </>
  );
}

4. 自定义Hooks中使用useReducer

自定义 Hook 解决了以前在 React 组件中无法灵活共享逻辑的问题。更重要的是,创建自定义 Hook 就像使用 React 内置的功能一样简单。

尽量避免过早地增加抽象逻辑。既然函数组件能够做的更多,那么代码库中函数组件的代码行数可能会剧增。
这属于正常现象 —— 不必立即将它们拆分为 Hook。但我们仍鼓励你能通过自定义 Hook 寻找可能,以达到简化代码逻辑,解决组件杂乱无章的目的。

例如,有个复杂的组件,其中包含了大量以特殊的方式来管理的内部状态。useState 并不会使得集中更新逻辑变得容易,因此你可能更愿意使用 redux 中的 reducer 来编写。

那么,为什么我们不编写一个 useReducer 的 Hook,使用 reducer 的方式来管理组件的内部 state 呢?其简化版本可能如下所示:

function useReducer(reducer, initialState) {
  const [state, setState] = useState(initialState);

  function dispatch(action) {
    const nextState = reducer(state, action);
    setState(nextState);
  }

  return [state, dispatch];
}

在组件中使用它,让 reducer 驱动它管理 state:

function Todos() {
  const [todos, dispatch] = useReducer(todosReducer, []);

  function handleAddClick(text) {
    dispatch({ type: 'add', text });
  }

  // ...
}

5. 异步 fetch Hooks

处理异步调用,应该包含以下内容:

  • 创建一个接受处理函数的自定义钩子,fn.
  • 为自定义hooks的状态定义一个 reducer 函数和一个初始状态。
  • 使用useReducer()钩子初始化state变量和dispatch函数。
  • 定义一个异步 run 函数,该函数将运行提供的回调 fn,同时根据需要使用 dispatch 更新 state
  • 返回一个包含state( value,errorloading) 和run函数的对象。

1). 封装 useAsync

  • ./customHooks/useAsync.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

import React, { useReducer } from 'react'

export const useAsync = fn => { //创建一个接受处理函数的自定义钩子,`fn`.
  const initialState = { loading: false, error: null, value: null }; //初始化状态
  const stateReducer = (_, action) => {//处理状态的逻辑
    switch (action.type) {
      case 'start':
        return { loading: true, error: null, value: null };
      case 'finish':
        return { loading: false, error: null, value: action.value };
      case 'error':
        return { loading: false, error: action.error, value: null };
    }
  };

  const [state, dispatch] = useReducer(stateReducer, initialState);//使用`useReducer()`钩子初始化`state`变量和`dispatch`函数

  const run = async (args = null) => {//定义一个异步 `run` 函数,该函数将运行提供的`回调 fn`,同时根据需要使用 `dispatch` 更新 `state`
    try {
      dispatch({ type: 'start' });
      const value = await fn(args);
      dispatch({ type: 'finish', value });
    } catch (error) {
      dispatch({ type: 'error', error });
    }
  };

  return { ...state, run };//返回一个包含`state`( `value`,`error`和`loading`) 和`run`函数的对象。
};

2). 在组件中使用

  • component
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

import { useAsync } from './customHooks/useAsync';

const RandomImage = props => {
  const imgFetch = useAsync(url =>
    fetch(url).then(response => response.json())
  );

  return (
    <div>
      <button
        onClick={() => imgFetch.run('https://dog.ceo/api/breeds/image/random')}
        disabled={imgFetch.isLoading}
      >
        Load image
      </button>
      <br />
      {imgFetch.loading && <div>Loading...</div>}
      {imgFetch.error && <div>Error {imgFetch.error}</div>}
      {imgFetch.value && (
        <img
          src={imgFetch.value.message}
          alt="avatar"
          width={400}
          height="auto"
        />
      )}
    </div>
  );
};

// ReactDOM.render(<RandomImage />, document.getElementById('root'));

四、ESLint 插件

React发布了一个名为 eslint-plugin-react-hooks 的 ESLint 插件来强制执行Hooks规则。

如果项目中没有自动安装,可自行安装配置:

yarn add --dev eslint-plugin-react-hooks
// 你的 ESLint 配置
{
  "plugins": [
    // ...
    "react-hooks"
  ],
  "rules": {
    // ...
    "react-hooks/rules-of-hooks": "error", // 检查 Hook 的规则
    "react-hooks/exhaustive-deps": "warn" // 检查 effect 的依赖
  }
}

五、Hooks API

1. useState

返回一个 state,以及更新 state 的函数。

const [state, setState] = useState(initialState);

等号左边名字并不是 React API 的部分,你可以自己取名字。
在初始渲染期间,返回的状态 (state) 与传入的第一个参数 (initialState) 值相同。
这种 JavaScript 语法叫数组解构。等价于

  var fruitStateVariable = useState('banana'); // 返回一个有两个元素的数组
  var fruit = fruitStateVariable[0]; // 数组里的第一个值
  var setFruit = fruitStateVariable[1]; // 数组里的第二个值

1). 普通式更新state

在后续的重新渲染中,useState 返回的第一个值将始终是更新后最新的 state。

setState(newState);

2). 函数式更新state

如果新的state需要通过使用先前的state计算得出,那么可以将函数传递给 setState。该函数将接收先前的 state,并返回一个更新后的值。

function Counter({initialCount}) {
  const [count, setCount] = useState(initialCount);
  return (
    <>
      Count: {count}
      <button onClick={() => setCount(initialCount)}>Reset</button>
      <button onClick={() => setCount(prevCount => prevCount - 1)}>-</button>
      <button onClick={() => setCount(prevCount => prevCount + 1)}>+</button>
    </>
  );
}

+- 按钮采用函数式形式,因为被更新的 state 需要基于之前的 state。但是重置按钮则采用普通形式,因为它总是把 count 设置回初始值。

如果你的更新函数返回值与当前 state 完全相同,则随后的重渲染会被完全跳过。

3). 合并更新对象

与 class 组件中的 setState 方法不同,useState 不会自动合并更新对象。可以用函数式的 setState 结合展开运算符来达到合并更新对象的效果。

const [state, setState] = useState({});
setState(prevState => {
  // 也可以使用 Object.assign
  return {...prevState, ...updatedValues};
});

useReducer 是另一种可选方案,它更适合用于管理包含多个子值的 state 对象。

4). 惰性初始 state

initialState 参数只会在组件的初始渲染中起作用,后续渲染时会被忽略。
如果初始 state 需要通过复杂计算获得,则可以传入一个函数,在函数中计算并返回初始的 state,此函数只在初始渲染时被调用:

const [state, setState] = useState(() => {
  const initialState = someExpensiveComputation(props);
  return initialState;
});

5). 跳过 state 更新

调用 State Hook 的更新函数setState并传入当前的 state 时,React 将跳过子组件的渲染及 effect 的执行。(React 使用Object.is比较算法来比较 state。)

需要注意的是,React 可能仍需要在跳过渲染前渲染该组件。不过由于 React 不会对组件树的“深层”节点进行不必要的渲染,所以大可不必担心。

如果在渲染期间执行了高开销的计算,则可以使用 useMemo 来进行优化。

2. useEffect

Effect Hook 可以让你在函数组件中执行副作用操作(改变 DOM、添加订阅、设置定时器、记录日志等)

赋值给 useEffect 的函数会在组件渲染到屏幕之后执行。

可以把 effect 看作从 React 的纯函数式世界通往命令式世界的逃生通道。

默认情况下,effect 将在每轮渲染结束后执行,但你可以选择让它 在只有某些值改变的时候 才执行。

useEffect(didUpdate);

1). 清除 effect

通常,组件卸载时需要清除 effect 创建的诸如订阅或计时器 ID 等资源。

要实现这一点,useEffect 函数需返回一个清除函数。以下就是一个创建订阅的例子:

useEffect(() => {
  const subscription = props.source.subscribe();
  return () => {
    // 清除订阅
    subscription.unsubscribe();
  };
});

为防止内存泄漏,清除函数会在组件卸载前执行。

另外,如果组件多次渲染(通常如此),则在执行下一个 effect 之前,上一个 effect 就已被清除。

2). effect 的执行时机

与 componentDidMount、componentDidUpdate 不同的是,传给 useEffect 的函数会在浏览器完成布局与绘制之后,在一个延迟事件中被调用。
这使得它适用于许多常见的副作用场景,比如设置订阅和事件处理等情况,因为绝大多数操作不应阻塞浏览器对屏幕的更新。

虽然 useEffect 会在浏览器绘制后延迟执行,但会保证在任何新的渲染前执行。
在开始新的更新前,React 总会先清除上一轮渲染的 effect。

3). useLayoutEffect

然而,并非所有 effect 都可以被延迟执行。例如,一个对用户可见的 DOM 变更就必须在浏览器执行下一次绘制前被同步执行,这样用户才不会感觉到视觉上的不一致。(概念上类似于被动监听事件和主动监听事件的区别。)React 为此提供了一个额外的 useLayoutEffect Hook 来处理这类 effect。它和 useEffect 的结构相同,区别只是调用时机不同

它会在所有的 DOM 变更之后同步调用 effect。可以使用它来读取 DOM 布局并同步触发重渲染。在浏览器执行绘制之前,useLayoutEffect 内部的更新计划将被同步刷新。

尽可能使用标准的 useEffect 以避免阻塞视觉更新。

如果你正在将代码从 class 组件迁移到使用 Hook 的函数组件,则需要注意 useLayoutEffect 与 componentDidMount、componentDidUpdate 的调用阶段是一样的。
但是,我们推荐你一开始先用 useEffect,只有当它出问题的时候再尝试使用 useLayoutEffect。

4). SSR 服务器端渲染

如果你使用服务端渲染,请记住,无论 useLayoutEffect 还是 useEffect 都无法在 Javascript 代码加载完成之前执行。
这就是为什么在服务端渲染组件中引入 useLayoutEffect 代码时会触发 React 告警。
解决这个问题,需要将代码逻辑移至 useEffect 中(如果首次渲染不需要这段逻辑的情况下),或是将该组件延迟到客户端渲染完成后再显示(如果直到 useLayoutEffect 执行之前 HTML 都显示错乱的情况下)。

若要从服务端渲染的 HTML 中排除依赖布局 effect 的组件,可以通过使用 showChild && <Child /> 进行条件渲染,并使用 useEffect(() => { setShowChild(true); }, []) 延迟展示组件。
这样,在客户端渲染完成之前,UI 就不会像之前那样显示错乱了。

5). effect 的条件执行1

默认情况下,effect 会在每轮组件渲染完成后执行。这样的话,一旦 effect 的依赖发生变化,它就会被重新创建。

然而,在某些场景下这么做可能会矫枉过正。比如,在上一章节的订阅示例中,我们不需要在每次组件更新时都创建新的订阅,而是仅需要在 source prop 改变时重新创建。

要实现这一点,可以给 useEffect 传递第二个参数,它是 effect 所依赖的值数组

更新后的示例如下:(此时,只有当 props.source 改变后才会重新创建订阅。)

useEffect(
  () => {
    const subscription = props.source.subscribe();
    return () => {
      subscription.unsubscribe();
    };
  },
  [props.source],
);

5). effect 的条件执行2

如果你要使用此优化方式,请确保数组中包含了所有外部作用域中会发生变化且在 effect 中使用的变量,否则你的代码会引用到先前渲染中的旧变量。

如果想执行只运行一次的 effect(仅在组件挂载和卸载时执行),可以传递一个空数组([])作为第二个参数。
这就告诉 React 你的 effect 不依赖于 props 或 state 中的任何值,所以它永远都不需要重复执行。这并不属于特殊情况 —— 它依然遵循输入数组的工作方式。

如果你传入了一个空数组([]),effect 内部的 props 和 state 就会一直持有其初始值
尽管传入 [] 作为第二个参数有点类似于 componentDidMount 和 componentWillUnmount 的思维模式,但它是更好的方式来避免过于频繁的重复调用 effect。

除此之外,请记得 React 会等待浏览器完成画面渲染之后才会延迟调用 useEffect,因此会使得处理额外操作很方便。

React推荐启用 eslint-plugin-react-hooks 中的 exhaustive-deps 规则。此规则会在添加错误依赖时发出警告并给出修复建议。

依赖项数组不会作为参数传给 effect 函数。虽然从概念上来说它表现为:所有 effect 函数中引用的值都应该出现在依赖项数组中。未来编译器会更加智能,届时自动创建数组将成为可能。

3. useContext

我个人绝得,useContext 只适合用来做UI主题的传递
const value = useContext(MyContext);

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

当组件上层最近的<MyContext.Provider>更新时,该 Hook 会触发重渲染,并使用最新传递给 MyContext providercontext value 值。

即使祖先使用 React.memoshouldComponentUpdate,也会在组件本身使用 useContext 时重新渲染。

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

正确: useContext(MyContext)
错误: useContext(MyContext.Consumer)
错误: useContext(MyContext.Provider)

调用了 useContext 的组件总会在 context 值变化时重新渲染。如果重渲染组件的开销较大,你可以 通过使用 memoization 来优化。

使用方法

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

const themes = {
  light: {
    foreground: "#000000",
    background: "#eeeeee"
  },
  dark: {
    foreground: "#ffffff",
    background: "#222222"
  }
};
------------------------Step1. 创建上下文----------------
const ThemeContext = React.createContext(themes.light);
--------------------------------------------------------
function App() {
  return (
------------------------Step2. 提供上下文----------------
    <ThemeContext.Provider value={themes.dark}>
      <Toolbar />
    </ThemeContext.Provider>
--------------------------------------------------------
  );
}

function Toolbar(props) {
  return (
    <div>
      <ThemedButton />
    </div>
  );
}

function ThemedButton() {
------------------------Step3. 使用上下文---------------------
  const theme = useContext(ThemeContext);
  return (
    <button style={{ background: theme.background, color: theme.foreground }}>
      I am styled by theme context!
    </button>
  );
--------------------------------------------------------------
}

4. useReducer

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

useState 的替代方案。它接收一个形如 (state, action) => newState 的 reducer,并返回当前的 state 以及与其配套的 dispatch 方法。(如果你熟悉 Redux 的话,就已经知道它如何工作了。)

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

1). 使用方法

以下是用 reducer 重写 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>
    </>
  );
}

2). 指定初始 state

有两种不同初始化 useReducer state 的方式,可以根据使用场景选择其中的一种。
将初始 state 作为第二个参数传入 useReducer 是最简单的方法:

const [state, dispatch] = useReducer(
    reducer,
    {count: initialCount}
  );

React 不使用 state = initialState 这一由 Redux 推广开来的参数约定。
有时候初始值依赖于 props,因此需要在调用 Hook 时指定。
如果特别喜欢Redux的参数约定,可以通过调用 useReducer(reducer, undefined, reducer) 来模拟 Redux 的行为,但React不鼓励这么做。

3). 惰性初始化

可以选择惰性地创建初始 state。为此,需要将 init 函数作为 useReducer 的第三个参数传入,这样初始 state 将被设置为 init(initialArg)

这么做可以将用于计算 state 的逻辑提取到 reducer 外部,这也为将来对重置 state 的 action 做处理提供了便利:

function init(initialCount) {
  return {count: initialCount};
}

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    case 'reset':
      return init(action.payload);
    default:
      throw new Error();
  }
}

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

4). 跳过 dispatch

如果 Reducer Hook 的返回值与当前 state 相同,React 将跳过子组件的渲染及副作用的执行。(React 使用 Object.is 比较算法 来比较 state。)

需要注意的是,React 可能仍需要在跳过渲染前再次渲染该组件。不过由于 React 不会对组件树的“深层”节点进行不必要的渲染,所以大可不必担心。
如果你在渲染期间执行了高开销的计算,则可以使用 useMemo 来进行优化。

5. useCallback

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

返回一个 memoized 回调函数。

Memoization 在函数式编程语言的编译器中大量使用,这些语言通常使用按名称调用评估策略。
为了避免计算参数值的开销,这些语言的编译器大量使用称为thunk 的辅助函数来计算参数值,并记住这些函数以避免重复计算

内联回调函数及依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新

当把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件时,它将非常有用。

useCallback(fn, deps) 相当于 useMemo(() => fn, deps)

依赖项数组不会作为参数传给回调函数。虽然从概念上来说它表现为:所有回调函数中引用的值都应该出现在依赖项数组中。未来编译器会更加智能,届时自动创建数组将成为可能。

React推荐启用 eslint-plugin-react-hooks 中的 exhaustive-deps 规则。此规则会在添加错误依赖时发出警告并给出修复建议。

1. useCallback()的目的

React 组件中通常会创建共享相同代码不同函数对象

1
2
3
4
5
6
7
8

function MyComponent() {
  // handleClick 在每次渲染时重新创建
  const handleClick = () => {
    console.log('Clicked!');
  };
  // ...
}

handleClickMyComponent每次渲染的不同函数对象。

因为内联函数很便宜,所以在每次渲染时重新创建函数不是问题。每个组件有几个内联函数是可以接受的。

但在某些情况下,需要在渲染之间维护单个函数实例

  1. 函数组件里面包裹一个接受函数对象的props的React.memo()
  2. 当函数对象依赖于其他钩子时,例如 useEffect(..., [callback])
  3. 当函数有一些内部状态时,例如去抖动函数
    (基本上去抖动确保为可能发生多次的事件发送恰好一个信号。
    节流将函数接收的调用频率限制在一个固定的时间间隔内。它用于确保目标函数的调用频率不会超过指定的延迟。)。

这时,useCallback(callbackFun, deps) 就派上用场了:给定相同的依赖值deps,钩子在渲染之间返回相同的函数实例(又名记忆):

1
2
3
4
5
6
7
8
9

import { useCallback } from 'react';
function MyComponent() {
  // handleClick is the same function object
  const handleClick = useCallback(() => {
    console.log('Clicked!');
  }, []);
  // ...
}

handleClick 变量在 MyComponent 的渲染之间始终具有相同的回调函数对象。

2. useCallback的正确用例

想象一下,有一个呈现大量数据列表的组件:

import useSearch from './fetch-items';
function MyBigList({ term, onItemClick }) {
  const items = useSearch(term);
  const map = item => <div onClick={onItemClick}>{item}</div>;
  return <div>{items.map(map)}</div>;
}
export default React.memo(MyBigList);

列表可能很大,可能有数百个项目。为了防止无用的列表重新呈现,将其包装到React.memo().

MyBigList 的父组件提供了一个知道何时单击了一个项目的处理函数:

import { useCallback } from 'react';
export function MyParent({ term }) {
--------------------------------------------------------
  const onItemClick = useCallback(event => {
    console.log('You clicked ', event.currentTarget);
  }, [term]);
--------------------------------------------------------
  return (
    <MyBigList
      term={term}
      onItemClick={onItemClick}
    />
  );
}

onItemClick回调由useCallback()处理. 只要term是相同的,useCallback()就返回相同的函数对象。

当MyParent组件重新渲染时,onItemClick函数对象保持不变并且不会破坏MyBigList.

这就是useCallback()的目的和正确用法。

3. useCallback的糟糕用例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

import { useCallback } from 'react';
function MyComponent() {
  // `useCallback()` 被无脑地使用,在这里毫无意义
  const handleClick = useCallback(() => {
    // 处理点击事件
  }, []);
  return <MyChild onClick={handleClick} />;
}
function MyChild ({ onClick }) {
  return <button onClick={onClick}>I am a child</button>;
}

通过使用useCallback()增加了代码复杂性。必须使depsofuseCallback(..., deps)与你在记忆化回调中使用的内容保持同步。

总之,优化比没有优化的成本更高。

在这里,完全可以如此处理:

import { useCallback } from 'react';
function MyComponent() {
  const handleClick = () => {
    // handle the click event
  };
  return <MyChild onClick={handleClick} />;
}
function MyChild ({ onClick }) {
  return <button onClick={onClick}>I am a child</button>;
}

4). 总结(所有性能优化的建议)

在考虑性能调整时,请记住以下语句:

  • 在优化之前先分析

在决定使用优化技术时,包括记忆化,特别是useCallback(),请执行以下操作:

  • 第一 分析
  • 然后量化增加的性能(例如150ms与50ms渲染速度增加)

然后问问自己:与增加的复杂性相比,增加的性能值得使用 useCallback() 吗?

6. useMemo

可以把 useMemo 作为性能优化的手段,但不要把它当成语义上的保证。
将来,React 可能会选择“遗忘”以前的一些 memoized 值,并在下次渲染时重新计算它们,比如为离屏组件释放内存。
先编写在没有 useMemo 的情况下也可以执行的代码 —— 之后再在代码中添加 useMemo,以达到优化性能的目的。

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

在初始渲染期间,useMemo(compute, dependencies)调用compute,记忆计​​算结果,并将其返回给组件。

如果在下一次渲染期间依赖项没有改变,则 useMemo() 不会调用 compute 但返回记忆值

但是如果在重新渲染期间依赖项发生变化,则 useMemo() 调用 compute,记忆新值并返回它。

这就是useMemo()钩子的本质。

如果你的计算回调使用propsstate值,请确保将这些值指示为依赖项

const memoizedResult = useMemo(() => {
  return expensiveFunction(propA, propB);
}, [propA, propB]);

1). useMemo() 一个例子

组件<CalculateFactorial />计算输入字段的数字的阶乘。

这是<CalculateFactorial />组件的可能实现:

import { useState } from 'react';
export function CalculateFactorial() {
  const [number, setNumber] = useState(1);
  const [inc, setInc] = useState(0);
  const factorial = factorialOf(number);
  const onChange = event => {
    setNumber(Number(event.target.value));
  };
  const onClick = () => setInc(i => i + 1);
  
  return (
    <div>
      Factorial of 
      <input type="number" value={number} onChange={onChange} />
      is {factorial}
      <button onClick={onClick}>Re-render</button>
    </div>
  );
}
function factorialOf(n) {
  console.log('factorialOf(n) called!');
  return n <= 0 ? 1 : n * factorialOf(n - 1);
}

每次更改输入值时,都会计算阶乘factorialOf(n)并’factorialOf(n) called!‘记录到控制台。

另一方面,每次单击重新渲染按钮时,inc状态值都会更新。更新inc状态值会触发<CalculateFactorial />重新渲染。
但是,作为次要效果,在重新渲染期间,阶乘会再次重新计算 -‘factorialOf(n) called!‘记录到控制台。

当组件重新渲染时,如何记住阶乘计算?这时候就是使用 useMemo() 的时候了。

通过使用useMemo(() => factorialOf(number), [number])而不是普通的 factorialOf(number)。React 记住阶乘计算。

改进<CalculateFactorial />并记住阶乘计算:

import { useState, useMemo } from 'react';
export function CalculateFactorial() {
  const [number, setNumber] = useState(1);
  const [inc, setInc] = useState(0);
-----------------------------------------------------------------------
  const factorial = useMemo(() => factorialOf(number), [number]);
-----------------------------------------------------------------------
  const onChange = event => {
    setNumber(Number(event.target.value));
  };
  const onClick = () => setInc(i => i + 1);
  
  return (
    <div>
      Factorial of 
      <input type="number" value={number} onChange={onChange} />
      is {factorial}
      <button onClick={onClick}>Re-render</button>
    </div>
  );
}
function factorialOf(n) {
  console.log('factorialOf(n) called!');
  return n <= 0 ? 1 : n * factorialOf(n - 1);
}

每次更改数字的值时,‘factorialOf(n) called!‘都会记录到控制台。这是预期的。

但是,如果您单击重新渲染按钮,‘factorialOf(n) called!‘则不会记录到控制台,因为useMemo(() => factorialOf(number), [number])返回记忆化的阶乘计算。很棒!

2). useMemo()与useCallback()

useCallback() 与 useMemo() 相比,是一个更专业的钩子,可以记住回调:

import { useCallback } from 'react';
function MyComponent({ prop }) {
  const callback = () => {
    return 'Result';
  };
  const memoizedCallback = useCallback(callback, [prop]);
  
  return <ChildComponent callback={memoizedCallback} />;
}

在上面的示例中,useCallback(() => {...}, [prop])只要prop依赖项相同,就返回相同的函数实例

可以使用useMemo()做相同的方式来记忆回调:

import { useMemo } from 'react';
function MyComponent({ prop }) {
  const callback = () => {
    return 'Result';
  };
  const memoizedCallback = useMemo(() => callback, [prop]);
  
  return <ChildComponent callback={memoizedCallback} />;
}

3). 小心使用记忆(memoization)

虽然useMemo()可以提高组件的性能,但必须确保使用和不使用挂钩来配置组件。只有在那之后才能得出是否值得记忆的结论。

当记忆使用不当时,可能会损害性能。

4). 结语

useMemo(() => computation(a, b), [a, b])是记住昂贵计算的钩子。
给定相同的[a, b]依赖项,一旦记忆,钩子将返回记忆值而不调用computation(a, b)

7. useRef

React.useRef()钩子创建持久化的可变值(也称为 ref/reference ),以及访问 DOM 元素。

1). 可变值

useRef(initialValue)是一个内置的 React 钩子,它接受一个参数作为初始值返回一个引用(又名ref)引用是具有特殊属性的对象current

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

import { useRef } from 'react';
function MyComponent() {
  const reference = useRef(initialValue);
  const someHandler = () => {
    // Access reference value:
    const value = reference.current;
    // Update reference value:
    reference.current = newValue;
  };
  // ...
}

reference.current 访问 ref 值,reference.current = newValue 更新 ref 值。很简单。

用例:记录按钮点击

该组件LogButtonClicks使用ref存储按钮的点击次数:

import { useRef } from 'react';
function LogButtonClicks() {
  const countRef = useRef(0);
  
  const handle = () => {
    countRef.current++;
    console.log(`Clicked ${countRef.current} times`);
  };
  console.log('I rendered!');
  return <button onClick={handle}>Click me</button>;
}

const countRef = useRef(0)创建一个用countRef初始化的ref0

单击按钮时,handle将调用函数并递增ref值countRef.current++。ref值记录到控制台。

更新ref值countRef.current++不会触发组件重新渲染。这可以通过'I rendered!'在初始渲染时仅记录到控制台一次的事实来证明,并且在更新ref时不会发生重新渲染。

现在提出一个合理的问题:refstate之间的主要区别是什么?

1.1). ref 和 state 的区别

重用LogButtonClicks组件,但这次使用useState()钩子来计算按钮点击次数:

import { useState } from 'react';
function LogButtonClicks() {
  const [count, setCount] = useState(0);
  
  const handle = () => {
    const updatedCount = count + 1;
    console.log(`Clicked ${updatedCount} times`);
    setCount(updatedCount);
  };
  console.log('I rendered!');
  return <button onClick={handle}>Click me</button>;
}

每次单击时,都会在控制台中看到消息’I rendered!’—— 这意味着每次更新状态时,组件都会重新渲染

因此,引用和状态之间的两个主要区别:

  1. 更新ref不会触发重新渲染,而更新state会使组件重新渲染;
  2. ref更新是同步的(更新后的ref值立即可用),而state更新是异步的(重新渲染后更新状态变量)。

从更高的角度来看,ref存储副作用的基础结构数据,而state存储直接呈现在屏幕上的信息。

用例:实现秒表

可以将副作用的ref基础结构数据存储在内部。例如,可以存储到ref指针中:计时器 ID、套接字 ID 等。

该组件Stopwatch使用setInterval(callback, time)计时器功能每秒增加秒数的计数器。计时器 id 存储到ref中timerIdRef

import { useRef, useState, useEffect } from 'react';
function Stopwatch() {
  const timerIdRef = useRef(0);
  const [count, setCount] = useState(0);
  const startHandler = () => {
    if (timerIdRef.current) { return; }
    timerIdRef.current = setInterval(() => setCount(c => c+1), 1000);
  };
  const stopHandler = () => {
    clearInterval(timerIdRef.current);
    timerIdRef.current = 0;
  };
  useEffect(() => {
    return () => clearInterval(timerIdRef.current);
  }, []);
  return (
    <div>
      <div>Timer: {count}s</div>
      <div>
        <button onClick={startHandler}>Start</button>
        <button onClick={stopHandler}>Stop</button>
      </div>
    </div>
  );
}

startHandler()单击Start按钮时调用timerIdRef.current = setInterval(...)函数启动计时器并将计时器 id 保存在ref中

用户单击停止按钮停止秒表。停止按钮处理程序stopHandler()从ref访问计时器ID,并停止定时器clearInterval(timerIdRef.current)

此外,如果组件在秒表处于活动状态的情况下卸载,则useEffect()的清理功能也将停止计时器。

在秒表示例中,ref用于存储基础设施数据 — 活动计时器 ID

附带挑战:通过添加重置按钮来改进秒表

//待编写

2). 访问 DOM 元素

useRef()钩子的另一个有用的应用是访问 DOM 元素。这分 3 个步骤执行:

  1. 定义访问元素的ref const elementRef = useRef()
  2. 将ref分配给ref元素的属性<div ref={elementRef}></div>;
  3. 挂载后,elementRef.current指向DOM元素。
import { useRef, useEffect } from 'react';
function AccessingElement() {
  const elementRef = useRef();
   useEffect(() => {
    const divElement = elementRef.current;
    console.log(divElement); // logs <div>I'm an element</div>
  }, []);
  return (
    <div ref={elementRef}>
      I'm an element
    </div>
  );
}

用例:聚焦输入

访问 DOM 元素,以便在组件挂载时关注输入字段。

要使其工作,需要创建对输入的ref,将ref分配给标签的ref属性,并在安装后调用element.focus()元素上的特殊方法。

这是该<InputFocus>组件的可能实现:

import { useRef, useEffect } from 'react';
function InputFocus() {
  const inputRef = useRef();
  useEffect(() => {
    inputRef.current.focus();
  }, []);
  return (
    <input 
      ref={inputRef} 
      type="text" 
    />
  );
}

const inputRef = useRef() 创建一个ref来保存输入元素。

然后inputRef分配给输入字段的属性ref:<input ref={inputRef} type="text" />

然后,在安装后,设置inputRef.current为输入元素。现在,可以通过编程将焦点设置输入到:inputRef.current.focus()

Ref 在初始渲染时为空

在初始渲染期间,保存 DOM 元素的ref应该是空的:

import { useRef, useEffect } from 'react';
function InputFocus() {
  const inputRef = useRef();
  useEffect(() => {
    // Logs `HTMLInputElement` 
    console.log(inputRef.current);
    inputRef.current.focus();
  }, []);
  // Logs `undefined` during initial rendering
  console.log(inputRef.current);
  return <input ref={inputRef} type="text" />;
}

在初始渲染期间,React 仍不确定组件的输出是什么,因此还没有创建 DOM 结构。这就是为什么在初始渲染期间inputRef.current评估为undefined。

useEffect(callback, []) 当输入元素已经在 DOM 中创建时,钩子在挂载后立即调用回调。

callback的函数useEffect(callback, [])使inputRef.current访问的正确位置,因为它可以保证构建 DOM。

3). 更新ref限制

功能组件的功能范围应该计算输出或调用钩子。

这就是为什么不应该在组件功能的直接范围内执行更新ref(以及更新state)的原因。

必须在useEffect()回调或处理程序(事件处理程序、计时器处理程序等)内部更新ref。

import { useRef, useEffect } from 'react';
function MyComponent({ prop }) {
  const myRef = useRef(0);
  useEffect(() => {
    myRef.current++; // Good!
    setTimeout(() => {
      myRef.current++; // Good!
    }, 1000);
  }, []);
  const handler = () => {
    myRef.current++; // Good!
  };
  myRef.current++; // Bad!
  if (prop) {
    myRef.current++; // Bad!
  }
  return <button onClick={handler}>My button</button>;
}

4). 总结

useRef() 钩子创建引用。

const reference = useRef(initialValue)使用初始值调用会返回一个名为 reference 的特殊对象。
ref对象有一个属性current:你可以使用这个属性来读取ref值reference.current,或者更新reference.current = newValue

在组件重新渲染之间,引用的值是持久的。

与更新状态相反,更新引用不会触发组件重新渲染。

引用也可以访问 DOM 元素。将引用分配给ref您要访问的元素的属性:<div ref={reference}>Element</div>— 该元素位于reference.current

8. useImperativeHandle

useImperativeHandle(ref, createHandle, [deps])

useImperativeHandle 可以让你在使用 ref 时自定义暴露给父组件的实例值。在大多数情况下,应当避免使用 ref 这样的命令式代码。

useImperativeHandle 应当与 forwardRef 一起使用:

function FancyInput(props, ref) {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    }
  }));
  return <input ref={inputRef} ... />;
}
FancyInput = forwardRef(FancyInput);

在本例中,渲染 <FancyInput ref={inputRef} /> 的父组件可以调用 inputRef.current.focus()

9. useDebugValue

useDebugValue(value)

useDebugValue 可用于在 React 开发者工具中显示自定义 hook 的标签。

例如,自定义 Hook 章节中描述的名为 useFriendStatus 的自定义 Hook:

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  // ...

  // 在开发者工具中的这个 Hook 旁边显示标签
  // e.g. "FriendStatus: Online"
  useDebugValue(isOnline ? 'Online' : 'Offline');

  return isOnline;
}

React 不推荐你向每个自定义 Hook 添加 debug 值。当它作为共享库的一部分时才最有价值。

延迟格式化 debug 值

在某些情况下,格式化值的显示可能是一项开销很大的操作。除非需要检查 Hook,否则没有必要这么做。

因此,useDebugValue 接受一个格式化函数作为可选的第二个参数
该函数只有在 Hook 被检查时才会被调用。它接受 debug 值作为参数,并且会返回一个格式化的显示值。

例如,一个返回 Date 值的自定义 Hook 可以通过格式化函数来避免不必要的 toDateString 函数调用:

useDebugValue(date, date => date.toDateString());