跳至主要內容

自定义 Hooks

YihuiReactReact Hooks大约 7 分钟

自定义 Hooks

自定义 Hooks 在形式上其实非常简单,就是声明一个名字以 use 开头的函数,比如 useCounter。这个函数在形式上和普通的 JavaScript 函数没有任何区别,你可以传递任意参数给这个 Hook,也可以返回任何值。

但是要注意,Hooks 和普通函数在语义上是有区别的,就在于函数中有没有用到其它 Hooks。

什么意思呢?就是说如果你创建了一个 useXXX 的函数,但是内部并没有用任何其它 Hooks,那么这个函数就不是一个 Hook,而只是一个普通的函数。但是如果用了其它 Hooks ,那么它就是一个 Hook。

举一个简单的例子,我们看到过一个简单计数器的实现,当时把业务逻辑都写在了函数组件内部,但其实是可以把业务逻辑提取出来成为一个 Hook。比如下面的代码:

import { useState, useCallback }from 'react';
 
function useCounter() {
  // 定义 count 这个 state 用于保存当前数值
  const [count, setCount] = useState(0);
  // 实现加 1 的操作
  const increment = useCallback(() => setCount(count + 1), [count]);
  // 实现减 1 的操作
  const decrement = useCallback(() => setCount(count - 1), [count]);
  // 重置计数器
  const reset = useCallback(() => setCount(0), []);
  
  // 将业务逻辑的操作 export 出去供调用者使用
  return { count, increment, decrement, reset };
}

一方面能让这个逻辑得到重用,另外一方面也能让代码更加语义化,并且易于理解和维护。

从这个例子,我们可以看到自定义 Hooks 的两个特点:

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

封装通用逻辑:useAsync

发起异步请求获取数据并显示在界面上。

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

那么有了这个 Hook,我们在组件中就只需要关心与业务逻辑相关的部分。比如代码可以简化成这样的形式:


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 能够管理 React 组件状态的能力,将一个组件中的某一部分状态独立出来,从而实现了通用逻辑的重用。

为什么非要用Hooks来封装:

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

监听浏览器状态:useScroll

虽然 React 组件基本上不需要关心太多的浏览器 API,但是有时候却是必须的:

  • 界面需要根据在窗口大小变化重新布局;
  • 在页面滚动时,需要根据滚动条位置,来决定是否显示一个“返回顶部”的按钮。

正如 Hooks 的字面意思是“钩子”,它带来的一大好处就是:可以让 React 的组件绑定在任何可能的数据源上。这样当数据源发生变化时,组件能够自动刷新。

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

“返回顶部“功能的实现:

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 就看成普通的函数,能隔离的尽量去做隔离,**从而让代码更加模块化,更易于理解和维护。

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

小结

Hooks 典型的四个使用场景:

  1. 抽取业务逻辑;
  2. 封装通用逻辑;
  3. 监听浏览器状态;
  4. 拆分复杂组件。

Hooks 作为普通函数,是可以传递任何参数的。