自定義Hooks:四個典型的使用場景


一、如何用好hook

  要用好 React Hooks,很重要的一點,就是要能夠從 Hooks 的角度去思考問題。要做到這一點其實也不難,就是在遇到一個功能開發的需求時,首先問自己一個問題:這個功能中的哪些邏輯可以抽出來成為獨立的 Hooks?

  這樣問的目的,是為了讓我們盡可能的吧業務陸奧及拆分成獨立的Hooks,這樣有助於實現代碼的模塊化和解耦,同時也方便后面的維護。hooks的連個核心的優點:

1.是方便進行邏輯復用

2.是幫助關注分離

 如何創建自定義Hooks

自定義Hooks在形式上其實非常簡單,就是聲明一個名字以use開頭的函數,比如useCounter。這個函數在形式上和普通函數沒有區別,你可以傳遞任意參數給這個Hooks。但是要注意,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 };
}

有了這個 Hook,我們就可以在組件中使用它,比如下面的代碼:

import React from 'react';

function Counter() {
  // 調用自定義 Hook
  const { count, increment, decrement, reset } = useCounter();

  // 渲染 UI
  return (
    <div>
      <button onClick={decrement}> - </button>
      <p>{count}</p>
      <button onClick={increment}> + </button>
      <button onClick={reset}> reset </button>
    </div>
  );
}

在這段代碼中,我們把原來在函數組件中實現的邏輯提取了出來,成為一個單獨的 Hook,一方面能讓這個邏輯得到重用,另外一方面也能讓代碼更加語義化,並且易於理解和維護。

從這個例子,我們可以看出自定義Hooks的兩個特點:

1.名字一定是以use開頭的函數,這樣react才能知道這個函數是一個Hooks;

2.函數內部一定調用了其他的Hooks,可以是內置的Hooks,也可以是自定義Hooks。這樣才能夠讓組件刷新,或者去產生副作用。

  封裝通用邏輯:useAsync

在組件的開發過程中,有一些常用的通用邏輯。過去可能應為邏輯重用比較繁瑣,而經常在每個組件中去自己實現,造成維護的困難。但現有了Hooks,就可以將更多的通用邏輯通過Hooks的形式進行封裝,方便被不同的組件重用。

比如說,在日常UI的開發中,有一個最常b見的需求:發起異步請求獲取數據並顯示在界面上。在這個過程中,我們不僅要關心請求正常返回時UI如何戰術數據;還需要處理請求出錯,以及慣出loding狀態在UI上如何顯示。

  我們可以重新看下在第 1 講中看到的異步請求的例子,從 Server 端獲取用戶列表,並顯示在界面上:

import React from "react";

export default function UserList() {
  // 使用三個 state 分別保存用戶列表,loading 狀態和錯誤狀態
  const [users, setUsers] = React.useState([]);
  const [loading, setLoading] = React.useState(false);
  const [error, setError] = React.useState(null);

  // 定義獲取用戶的回調函數
  const fetchUsers = async () => {
    setLoading(true);
    try {
      const res = await fetch("https://reqres.in/api/users/");
      const json = await res.json();
      // 請求成功后將用戶數據放入 state
      setUsers(json.data);
    } catch (err) {
      // 請求失敗將錯誤狀態放入 state
      setError(err);
    }
    setLoading(false);
  };

  return (
    <div className="user-list">
      <button onClick={fetchUsers} disabled={loading}>
        {loading ? "Loading..." : "Show Users"}
      </button>
      {error && 
        <div style={{ color: "red" }}>Failed: {String(error)}</div>
      }
      <br />
      <ul>
        {users && users.length > 0 &&
          users.map((user) => {
            return <li key={user.id}>{user.first_name}</li>;
          })}
      </ul>
    </div>
  );
}

在這里,我們定義了users、loading和error三個狀態。如果我們在異步請求的不同階段去設置不同的轉台,這樣Ui最終能夠根據這些狀態展示出來,在每個需要異步請求的組件中,其實都需要重復的邏輯。

事實上,在處理這類請求的時候,模式都是類似的,通常都會遵循下面步驟:

1.創建data,loding,error這3個state;

2.請求發出后,設置loading state為true;

3.請求成功后,將返回的數據放到某個state中,並將loading state設置false;

4.請求失敗后,設置error state為true,並將loading state設為false。

最后,基於data、loading、error這3個state的數據,ui就可以正確的顯示數據,或者loading、error這些反饋給客戶了。

所以,通過創建一個自定義Hook,可以很好的將這樣的邏輯提取出來,成為一個可重用的模塊。比如代碼可以這樣實現:

import React from "react";

export default function UserList() {
  // 使用三個 state 分別保存用戶列表,loading 狀態和錯誤狀態
  const [users, setUsers] = React.useState([]);
  const [loading, setLoading] = React.useState(false);
  const [error, setError] = React.useState(null);

  // 定義獲取用戶的回調函數
  const fetchUsers = async () => {
    setLoading(true);
    try {
      const res = await fetch("https://reqres.in/api/users/");
      const json = await res.json();
      // 請求成功后將用戶數據放入 state
      setUsers(json.data);
    } catch (err) {
      // 請求失敗將錯誤狀態放入 state
      setError(err);
    }
    setLoading(false);
  };

  return (
    <div className="user-list">
      <button onClick={fetchUsers} disabled={loading}>
        {loading ? "Loading..." : "Show Users"}
      </button>
      {error && 
        <div style={{ color: "red" }}>Failed: {String(error)}</div>
      }
      <br />
      <ul>
        {users && users.length > 0 &&
          users.map((user) => {
            return <li key={user.id}>{user.first_name}</li>;
          })}
      </ul>
    </div>
  );
}

那么有了這個 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進行封裝呢?

答案很容易就能想到。應為在Hooks中,你可以管理當前組件的state,從而將更多的邏輯寫在可重用的Hooks中。但是要知道,在普通的工具類中時無法直接修改組件的state的,那么也就無法在數據改變的時候觸發組件的重新渲染。

  監聽瀏覽器狀態:useScroll

雖然React組件基本上不需要關心太多的瀏覽器API,但是有時候卻是必須的:

1.界面需要根據窗口重新布局;

2.在頁面滾動時,需要根據滾動位置來決定是否顯示一個”返回頂部“的按鈕。

這都需要用到瀏覽器的api來監聽這些狀態的變化。那么我們就可以滾動條位置的場景為例,來看看因該如何用Hooks優雅的監聽瀏覽器狀態。

正如Hooks的字面意思時”鈎子“,他帶來的好處就是可以讓React的組件綁定在任何可能的數據源上。這樣當數據源發生變化時,組件能夠自動刷新。把這個好處對應到滾動條這個場景就是:組件需要綁定到滾動條的位置數據上。

雖然這個邏輯在函數組件中能直接實現,但是把這個邏輯實現為一個獨立的Hooks,既可以達到邏輯重用,在語義化也更加清晰。這和上面的useAsync的作用非常類似的。

我們可以直接來看這個Hooks因該如何實現:

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

 通過這個例子,我們看到了如何將瀏覽器狀態變成可被 React 組件綁定的數據源,從而在使用上更加便捷和直觀。當然,除了窗口大小、滾動條位置這些狀態,還有其它一些數據也可以這樣操作,比如 cookies,localStorage, URL,等等。你都可以通過這樣的方法來實現。

  拆分復雜組件

怎樣能使函數組件不會太冗余呢?做法很簡單,就是盡量將相關的邏輯做成獨立的Hooks,然后再函數組件中使用這些Hooks,通過參數傳遞和返回值讓Hooks之間完成交互

為了讓你對這一點有更直觀的感受,我們來看一個例子。設想現在有這樣一個需求:我們需要展示一個博客文章的列表,並且有一列要顯示文章的分類。同時,我們還需要提供表格過濾功能,以便能夠只顯示某個分類的文章。為了支持過濾功能,后端提供了兩個 API:一個用於獲取文章的列表,另一個用於獲取所有的分類。這就需要我們在前端將文章列表返回的數據分類 ID 映射到分類的名字,以便顯示在列表里。

  還是老生常談的那句話,改變這個狀況的關鍵仍然在於開發思路的轉變。我們要真正把 Hooks 就看成普通的函數,能隔離的盡量去做隔離,從而讓代碼更加模塊化,更易於理解和維護。那么針對這樣一個功能,我們甚至可以將其拆分成 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>
  );
}

通過這樣的方式,我們就把一個較為復雜的邏輯拆分成一個個獨立的 Hook 了,不僅隔離了業務邏輯,也讓代碼在語義上更加明確。比如說有 useArticles、useCategories 這樣與業務相關的名字,就非常易於理解。雖然這個例子中抽取出來的 Hooks 都非常簡單,甚至看上去沒有必要。但是實際的開發場景一定是比這個復雜的,比如對於 API 返回的數據需要做一些數據的轉換,進行數據的緩存,等等。那么這時就要避免把這些邏輯都放到一起,而是就要拆分到獨立的 Hooks,以免產生過於復雜的組件。到時候你也就更能更體會到 Hooks 帶給你的驚喜了。

  小結:

好了,這篇文章要給你介紹了自定義 Hooks 的概念,以及典型的四個使用場景:

  1.抽離業務邏輯層;

  2.封裝通用邏輯

  3.監聽瀏覽器狀態

  4.拆分復雜組件。

其中,我通過四個案例來幫助你真正理解 Hooks ,並熟練掌握自定義 Hooks 的用法。應始終記得,要用 Hooks 的思路去解決問題,發揮 Hooks 的最大價值,就是要經常去思考哪些邏輯應該封裝到一個獨立的 Hook,保證每個 Hook 的短小精悍,從而讓代碼更加清晰,易於理解和維護。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM