一文带你轻松上手 React Hooks

2,858 阅读7分钟

开篇概览

通过学习《React Hooks 核心原理与实战》课程,从以下3个部分来介绍 React Hooks

  1. React Hooks 的由来
  2. Hooks 的基本用法
  3. 自定义 Hooks 应用

往期 React 相关回顾
初学React,需要了解哪些知识点?

1. React Hooks 的由来

在 Hooks 出现之前,组件有 Class组件函数式组件 两种形式,
Class 作为 React 组件时,有两方面特性没有很好的发挥出来:

  • React 组件之间很少会用到继承,比如说我们创建一个 Button 组件,然后在创建一个 DropdownButton 去继承它这样使用
  • 所有 UI 都是由状态驱动,我们很少会在外部去调用一个组件实例,因为组件的方法通常在内部调用,或者生命周期方法是被自动调用等

函数式组件没有得到大规模使用的原因:

  • 必须是纯函数,自身无状态
  • 无法提供生命周期机制

结合以往的经验,React 提供给我们一个更理想的机制,那就是 Hooks:

  • 可以把外部数据绑定到函数的执行(函数组件有自己的状态)
  • 数据变化时,函数可自动重新执行(类似生命周期)
graph LR
node1(State) --> Execution --> node((Result))
node2(URL) --> Execution
node3(Window-Size) --> Execution

Hooks(钩子),把目标结果钩到某个可能发生变化的依赖(数据源或事件源)上,被钩的依赖发生变化,产生这个目标结果的代码会重新执行,得到更新后的结果

上图中可以看出,绑定了 State 、URL 以及 Window Size 的函数,当这些依赖项发生改变时会重新执行某个函数,然后的到新的结果
值得注意的是,Hooks 中绑定的依赖项还可以是其他 Hook 执行的结果,这样可以更好的进行逻辑复用

Class组件 实现监听窗口大小

以 Class 结合高阶组件的形式来完成逻辑复用,缺点:

  • 每一个高阶组件都会多一层额外节点,调试麻烦
  • 代码不直观,难以维护
const withWindowSize = Component => {
  // 产生一个高阶组件 WrappedComponent,只包含监听窗口大小的逻辑
  class WrappedComponent extends React.PureComponent {
    constructor(props) {
      super(props);
      this.state = {
        size: this.getSize()
      };
    }
    componentDidMount() {
      window.addEventListener("resize", this.handleResize); 
    }
    componentWillUnmount() {
      window.removeEventListener("resize", this.handleResize);
    }
    getSize() {
      return window.innerWidth > 1000 ? "large""small";
    }
    handleResize = ()=> {
      const currentSize = this.getSize();
      this.setState({
        size: this.getSize()
      });
    }
    render() {
      // 将窗口大小传递给真正的业务逻辑组件
      return <Component size={this.state.size} />;
    }
  }
  return WrappedComponent;
};


class MyComponent extends React.Component{
  render() {
    const { size } = this.props;
    if (size === "small") return <SmallComponent />;
    else return <LargeComponent />;
  }
}
// 使用 withWindowSize 产生高阶组件,用于产生 size 属性传递给真正的业务组件
export default withWindowSize(MyComponent); 

Hooks 实现监听窗口大小

相对比 Class 高阶组件,将一个外部数状态使用 Hooks 进行封装,变成可绑定的数据源,窗口发生变化时 Hook 的组件就会重新渲染,且代码相对简洁、不会产生新的节点

const getSize = () => {
  return window.innerWidth > 1000 ? "large" : "small";
}
const useWindowSize = () => {
  const [size, setSize] = useState(getSize());
  useEffect(() => {
  const handler = () => {
      setSize(getSize())
    };
    window.addEventListener('resize', handler);
    return () => {
      window.removeEventListener('resize', handler);
    };
  }, []);
  
  return size;
};


const MyComponent = () => {
  const size = useWindowSize();
  if (size === "small") return <SmallComponent />;
  else return <LargeComponent />;
};

Hooks 有助于关注分离

Hooks 能够让同一个业务逻辑的代码尽可能聚合在一起,Class 组件的处理方式会相对分散,下图左侧为 Class 组件实现,右图为 Hooks 实现

image.png

总结

  1. 相对比 Class组件 和 旧的函数式组件,Hooks 能够更好的体现从 State => View 的函数式映射
  2. Hooks 更加简洁,逻辑复用方便

2. Hooks 的基本用法

Hooks 本身作为纯粹的 JavaScript 函数,不是通过某个特殊的 API 去创建的,而是直接定义一个函数,它需要在降低学习和使用成本的同时,还需要遵循一定的规则才能正常工作:

  • 只能在函数组件的顶级作用域使用(Hooks 不能在循环、条件判断或者嵌套函数内执行)
  • 只能在函数组件或者其他 Hooks 中使用

Hooks 只能在函数组件的顶级作用域使用

Hooks 在组件的多次渲染之间,必须按顺序被执行;因为在 React 组件内部,其实是维护了一个对应组件的固定 Hooks 执行列表,以便在多次渲染之间保持 Hooks 的状态,并做对比。

function MyComp() {
  const [count, setCount] = useState(0);
  if (count > 10) {
    // 错误:不能将 Hook 用在条件判断里
    useEffect(() => {
      // ...
    }, [count])
  }
  
  // 这里可能提前返回组件渲染结果,后面就不能再用 Hooks 了
  if (count === 0) {
    return 'No content';
  }

  // 错误:不能将 Hook 放在可能的 return 之后
  const [loading, setLoading] = useState(false);
  
  //...
  return <div>{count}</div>
}

所以 Hooks 的这个规则可以总结为两点:

  • 所有 Hook 必须要被执行到
  • 必须按顺序执行

Hooks 只能在函数组件或者其它 Hooks 中使用

Hooks 作为专门为函数组件设计的机制,使用的情况只有两种:

  • 在函数组件内
  • 在自定义的 Hooks 里面

如果一定要在 Class 组件中使用,那应该如何做呢?
答:利用高阶组件的模式,将 Hooks 封装成高阶组件,从而让类组件使用

import React from 'react';
import { useWindowSize } from '../hooks/useWindowSize';

export const withWindowSize = (Comp) => {
  return props => {
    const windowSize = useWindowSize();
    return <Comp windowSize={windowSize} {...props} />;
  };
};

通过 withWindowSize 高阶组件模式,可以把 useWindowSize 的结果作为属性,传递给需要使用窗口大小的类组件,这样就可以实现在 Class 组件中复用 Hooks 的逻辑了

import React from 'react';
import { withWindowSize } from './withWindowSize';

class MyComp {
  render() {
    const { windowSize } = this.props;
    // ...
  }
}

// 通过 withWindowSize 高阶组件给 MyComp 添加 windowSize 属性
export default withWindowSize(MyComp);

useState:让函数组件具有维持状态的能力

  • useState(initialState) 创建初始 state,initialState 可以是任意类型(数字、字符串、对象、数组...)
  • useState() 返回值为拥有两个元素的数组,第一个数组元素用来读取 state,第二个数组元素用来设置 state

Class 中的状态 state 只有一个,且为对象,通过对象中的不同属性来表示各种状态
Hooks 形式可创建多个 state ,对比 Class 的对象形式更加语义化


import React, { useState } from 'react';

function Example() {
  // 创建一个保存 count 的 state,并给初始值 0
  // setCount 用来设置 count 的值
  const [count, setCount] = useState(0);
  // 定义一个新状态,年龄的 state,初始值是 42
  const [age, setAge] = useState(42);

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>
        +
      </button>
      
      <p>{age}</p>
    </div>
  );
}

useEffect:执行副作用

  • useEffect(callback, dependencies)
    • callback 要执行的函数
    • dependencies 依赖数组
      • 不传此项,每次执行该函数组件时都会执行 callback
      • 指定依赖项,只有对应依赖发生改变才会执行 callback
      • 传空数组,函数组件首次渲染时执行,等价于 class组件中的 componentDidMount
  • useEffect() 返回一个函数,可用于组件销毁时的处理操作

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

function BlogView({ id }) {
  // 设置一个本地 state 用于保存 blog 内容
  const [blogContent, setBlogContent] = useState(null);

  useEffect(() => {
    // useEffect 的 callback 要避免直接的 async 函数,需要封装一下
    const doAsync = async () => {
      // 当 id 发生变化时,将当前内容清楚以保持一致性
      setBlogContent(null);
      // 发起请求获取数据
      const res = await fetch(`/blog-content/${id}`);
      // 将获取的数据放入 state
      setBlogContent(await res.text());
    };
    doAsync();
  }, [id]); // 使用 id 作为依赖项,变化时则执行副作用

  // 如果没有 blogContent 则认为是在 loading 状态
  const isLoading = !blogContent;
  return <div>{isLoading ? "Loading..." : blogContent}</div>;
}
useEffect(() => {
  // 每次 render 完一定执行
  console.log('re-rendered');
});

useEffect(() => {
  // 组件首次渲染时执行,等价于 class 组件中的 componentDidMount
  console.log('did mount');
}, [])


useEffect(() => {
  // componentDidMount + componentDidUpdate
  console.log('这里基本等价于 componentDidMount + componentDidUpdate');
  return () => {
    // componentWillUnmount
    console.log('这里基本等价于 componentWillUnmount');
  }
}, [deps])

useCallback:缓存回调函数

缓存函数组件内部函数 handleIncrement,避免每次重新渲染时都要重新创建 handleIncrement 函数

  • useCallback(fn, deps) 只有当某个依赖变量发生变化时,才会重新声明 fn 这个回调函数
import React, { useState, useCallback } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  const handleIncrement = useCallback(
    () => setCount(count + 1),
    [count], // 只有当 count 发生变化时,才会重新创建回调函数
  );
  // ...
  return <button onClick={handleIncrement}>+</button>
}

useMemo:缓存计算的结果

避免需要用到的数据没发生变化时进行重复计算

  • useMemo(fn, deps)

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

export default function SearchUserList() {
  const [users, setUsers] = useState(null);
  const [searchKey, setSearchKey] = useState("");

  useEffect(() => {
    const doFetch = async () => {
      // 组件首次加载时发请求获取用户数据
      const res = await fetch("https://reqres.in/api/users/");
      setUsers(await res.json());
    };
    doFetch();
  }, []);
  
  
  // let usersToShow = null;

  // if (users) {
  //   // 无论组件为何刷新,这里一定会对数组做一次过滤的操作
  //   usersToShow = users.data.filter((user) =>
  //     user.first_name.includes(searchKey),
  //   );
  //  }
  
  // 使用 userMemo 缓存计算的结果
  const usersToShow = useMemo(() => {
      if (!users) return null;
      return users.data.filter((user) => {
        return user.first_name.includes(searchKey));
      }
  }, [users, searchKey]);


  return (
    <div>
      <input
        type="text"
        value={searchKey}
        onChange={(evt) => setSearchKey(evt.target.value)}
      />
      <ul>
        {usersToShow &&
          usersToShow.length > 0 &&
          usersToShow.map((user) => {
            return <li key={user.id}>{user.first_name}</li>;
          })}
      </ul>
    </div>
  );
}

useRef:在多次渲染之间共享数据

可以把 useRef 看作是在函数组件之外创建的一个容器空间,在这个容器上,我们可以通过唯一的 current 属设置一个值,从而在函数组件的多次渲染之间共享这个值

使用 useRef 保存的数据一般是和 UI 的渲染无关的,因此当 ref 的值发生变化时,是不会触发组件的重新渲染的,这也是 useRef 区别于 useState 的地方

import React, { useState, useCallback, useRef } from "react";

export default function Timer() {
  // 定义 time state 用于保存计时的累积时间
  const [time, setTime] = useState(0);

  // 定义 timer 这样一个容器用于在跨组件渲染之间保存一个变量
  const timer = useRef(null);

  // 开始计时的事件处理函数
  const handleStart = useCallback(() => {
    // 使用 current 属性设置 ref 的值
    timer.current = window.setInterval(() => {
      setTime((time) => time + 1);
    }, 100);
  }, []);

  // 暂停计时的事件处理函数
  const handlePause = useCallback(() => {
    // 使用 clearInterval 来停止计时
    window.clearInterval(timer.current);
    timer.current = null;
  }, []);

  return (
    <div>
      {time / 10} seconds.
      <br />
      <button onClick={handleStart}>Start</button>
      <button onClick={handlePause}>Pause</button>
    </div>
  );
}

useRef 还有一个重要的功能,就是保存某个 DOM 节点的引用

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // current 属性指向了真实的 input 这个 DOM 节点,从而可以调用 focus 方法
    inputEl.current.focus();
  };
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

useContext:定义全局状态

区别于普通的全局变量,useContext 能够进行数据的绑定,定义全局的响应式数据,当这个 Context 的数据发生变化时,使用这个数据的组件就能够自动刷新


const themes = {
  light: {
    foreground: "#000000",
    background: "#eeeeee"
  },
  dark: {
    foreground: "#ffffff",
    background: "#222222"
  }
};
// 创建一个 Theme 的 Context

const ThemeContext = React.createContext(themes.light);
function App() {
  // 整个应用使用 ThemeContext.Provider 作为根组件
  return (
    // 使用 themes.dark 作为当前 Context 
    <ThemeContext.Provider value={themes.dark}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

// 在 Toolbar 组件中使用一个会使用 Theme 的 Button
function Toolbar(props) {
  return (
    <div>
      <ThemedButton />
    </div>
  );
}

// 在 Theme Button 中使用 useContext 来获取当前的主题
function ThemedButton() {
  const theme = useContext(ThemeContext);
  return (
    <button style={{
      background: theme.background,
      color: theme.foreground
    }}>
      I am styled by theme context!
    </button>
  );
}

3. 自定义 hooks 应用

自定义 Hooks 的两个特点:

  1. 名字一定是以 use 开头的函数,这样 React 才能够知道这个函数是一个 Hook;
  2. 函数内部一定调用了其它的 Hooks,可以是内置的 Hooks,也可以是其它自定义 Hooks,这样才能够让组件刷新,或者去产生副作用。

封装通用逻辑:useAsync

在组件的开发过程中,有一些常用的通用逻辑,最常见的需求:发起异步请求获取数据并显示在界面上
在这个过程中,我们不仅要关心请求正确返回时,UI 会如何展现数据
还需要处理请求出错,以及关注 Loading 状态在 UI 上如何显示
通常都会遵循下面步骤:

  1. 创建 data,loading,error 这 3 个 state;
  2. 请求发出后,设置 loading state 为 true;
  3. 请求成功后,将返回的数据放到某个 state 中,并将 loading state 设为 false;
  4. 请求失败后,设置 error state 为 true,并将 loading state 设为 false。
import { useState } from 'react';

const useAsync = (asyncFunction) => {
  // 设置三个异步逻辑相关的 state
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  // 定义一个 callback 用于执行异步逻辑
  const execute = useCallback(() => {
    // 请求开始时,设置 loading 为 true,清除已有数据和 error 状态
    setLoading(true);
    setData(null);
    setError(null);
    return asyncFunction()
      .then((response) => {
        // 请求成功时,将数据写进 state,设置 loading 为 false
        setData(response);
        setLoading(false);
      })
      .catch((error) => {
        // 请求失败时,设置 loading 为 false,并设置错误状态
        setError(error);
        setLoading(false);
      });
  }, [asyncFunction]);

  return { execute, loading, data, error };
};

在其他组件中使用 useAsync 时就只需关心与业务逻辑:

import React from "react";
import useAsync from './useAsync';

export default function UserList() {
  // 通过 useAsync 这个函数,只需要提供异步逻辑的实现
  const {
    execute: fetchUsers,
    data: users,
    loading,
    error,
  } = useAsync(async () => {
    const res = await fetch("https://reqres.in/api/users/");
    const json = await res.json();
    return json.data;
  });
  
  return (
    // 根据状态渲染 UI...
  );
}

为什么不直接封装普通的工具类,而是用 Hooks?
答:因为在 Hooks 中,你可以管理当前组件的 state,从而将更多的逻辑写在可重用的 Hooks 中;在普通的工具类中是无法直接修改组件 state 的,那么也就无法在数据改变的时候触发组件的重新渲染。

监听浏览器状态:useScroll

虽然 React 组件基本上不需要关心太多的浏览器 API,但是有时候却是必须的:界面需要根据在窗口大小变化重新布局;在页面滚动时,需要根据滚动条位置,来决定是否显示一个“返回顶部”的按钮。

import { useState, useEffect } from 'react';

// 获取横向,纵向滚动条位置
const getPosition = () => {
  return {
    x: document.body.scrollLeft,
    y: document.body.scrollTop,
  };
};
const useScroll = () => {
  // 定一个 position 这个 state 保存滚动条位置
  const [position, setPosition] = useState(getPosition());
  useEffect(() => {
    const handler = () => {
      setPosition(getPosition(document));
    };
    // 监听 scroll 事件,更新滚动条位置
    document.addEventListener("scroll", handler);
    return () => {
      // 组件销毁时,取消事件监听
      document.removeEventListener("scroll", handler);
    };
  }, []);
  return position;
};

有了这个 Hook,你就可以非常方便地监听当前浏览器窗口的滚动条位置了,比如 返回顶部 功能的实现

import React, { useCallback } from 'react';
import useScroll from './useScroll';

function ScrollTop() {
  const { y } = useScroll();

  const goTop = useCallback(() => {
    document.body.scrollTop = 0;
  }, []);

  const style = {
    position: "fixed",
    right: "10px",
    bottom: "10px",
  };
  // 当滚动条位置纵向超过 300 时,显示返回顶部按钮
  if (y > 300) {
    return (
      <button onClick={goTop} style={style}>
        Back to Top
      </button>
    );
  }
  // 否则不 render 任何 UI
  return null;
}

拆分复杂组件

尽量将相关的逻辑做成独立的 Hooks,然后在函数组中使用这些 Hooks,通过参数传递和返回值让 Hooks 之间完成交互

拆分逻辑的目的不一定是为了重用,而可以是为了业务逻辑的隔离;所以我们不一定要把 Hooks 放到独立的文件中,而是可以和函数组件写在一个文件中。这么做的原因就在于,这些 Hooks 是和当前函数组件紧密相关的,所以写到一起,反而更容易阅读和理解

看一个示例,展示一个博客文章的列表,有一列要显示文章的分类,还需要提供表格过滤功能,以便能够只显示某个分类的文章

后端提供了两个 API: 一个用于获取文章的列表,另一个用于获取所有的分类

这就需要我们在前端将文章列表返回的数据分类 ID 映射到分类的名字,以便显示在列表里

如果按照直观的思路去实现,通常都会把逻辑都写在一个组件里,比如类似下面的代码:

function BlogList() {
  // 获取文章列表...
  // 获取分类列表...
  // 组合文章数据和分类数据...
  // 根据选择的分类过滤文章...
  
  // 渲染 UI ...
}

针对这样一个功能,我们甚至可以将其拆分成 4 个 Hooks,每一个 Hook 都尽量小,代码如下:

import React, { useEffect, useCallback, useMemo, useState } from "react";
import { Select, Table } from "antd";
import _ from "lodash";
import useAsync from "./useAsync";

const endpoint = "https://myserver.com/api/";
const useArticles = () => {
  // 使用上面创建的 useAsync 获取文章列表
  const { execute, data, loading, error } = useAsync(
    useCallback(async () => {
      const res = await fetch(`${endpoint}/posts`);
      return await res.json();
    }, []),
  );
  // 执行异步调用
  useEffect(() => execute(), [execute]);
  // 返回语义化的数据结构
  return {
    articles: data,
    articlesLoading: loading,
    articlesError: error,
  };
};
const useCategories = () => {
  // 使用上面创建的 useAsync 获取分类列表
  const { execute, data, loading, error } = useAsync(
    useCallback(async () => {
      const res = await fetch(`${endpoint}/categories`);
      return await res.json();
    }, []),
  );
  // 执行异步调用
  useEffect(() => execute(), [execute]);

  // 返回语义化的数据结构
  return {
    categories: data,
    categoriesLoading: loading,
    categoriesError: error,
  };
};
const useCombinedArticles = (articles, categories) => {
  // 将文章数据和分类数据组合到一起
  return useMemo(() => {
    // 如果没有文章或者分类数据则返回 null
    if (!articles || !categories) return null;
    return articles.map((article) => {
      return {
        ...article,
        category: categories.find(
          (c) => String(c.id) === String(article.categoryId),
        ),
      };
    });
  }, [articles, categories]);
};
const useFilteredArticles = (articles, selectedCategory) => {
  // 实现按照分类过滤
  return useMemo(() => {
    if (!articles) return null;
    if (!selectedCategory) return articles;
    return articles.filter((article) => {
      console.log("filter: ", article.categoryId, selectedCategory);
      return String(article?.category?.name) === String(selectedCategory);
    });
  }, [articles, selectedCategory]);
};

const columns = [
  { dataIndex: "title", title: "Title" },
  { dataIndex: ["category", "name"], title: "Category" },
];

export default function BlogList() {
  const [selectedCategory, setSelectedCategory] = useState(null);
  // 获取文章列表
  const { articles, articlesError } = useArticles();
  // 获取分类列表
  const { categories, categoriesError } = useCategories();
  // 组合数据
  const combined = useCombinedArticles(articles, categories);
  // 实现过滤
  const result = useFilteredArticles(combined, selectedCategory);

  // 分类下拉框选项用于过滤
  const options = useMemo(() => {
    const arr = _.uniqBy(categories, (c) => c.name).map((c) => ({
      value: c.name,
      label: c.name,
    }));
    arr.unshift({ value: null, label: "All" });
    return arr;
  }, [categories]);

  // 如果出错,简单返回 Failed
  if (articlesError || categoriesError) return "Failed";

  // 如果没有结果,说明正在加载
  if (!result) return "Loading...";

  return (
    <div>
      <Select
        value={selectedCategory}
        onChange={(value) => setSelectedCategory(value)}
        options={options}
        style={{ width: "200px" }}
        placeholder="Select a category"
      />
      <Table dataSource={result} columns={columns} />
    </div>
  );
}

参考资料