一、如何用好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 的短小精悍,從而讓代碼更加清晰,易於理解和維護。
