用動畫和實戰打開 React Hooks(二):自定義 Hook 和 useCallback

本文由圖雀社區成員 mRc 寫做而成,歡迎加入圖雀社區,一塊兒創做精彩的免費技術教程,予力編程行業發展。javascript

若是您以爲咱們寫得還不錯,記得 點贊 + 關注 + 評論 三連,鼓勵咱們寫出更好的教程💪html

在第二篇教程中,咱們將手把手帶你用自定義 Hook 重構以前的組件代碼,讓它變得更清晰、而且能夠實現邏輯複用。在重構完成以後,咱們陷入了組件「不斷獲取數據並從新渲染」的無限循環,這時候,useCallback 站了出來,如同定海神針通常拯救了咱們的應用……前端

歡迎訪問本項目的 GitHub 倉庫Gitee 倉庫java

自定義 Hook:量身定製

上一篇教程中,咱們經過動畫的方式不斷深刻 useStateuseEffect,基本上理清了 React Hooks 背後的實現機制——鏈表,同時也實現了 COVID-19 數據可視化應用的全球數據總覽和多個國家數據的直方圖。react

若是你想直接從這一篇教程開始閱讀和實踐,可下載本教程的源碼:git

git clone -b second-part https://github.com/tuture-dev/covid-19-with-hooks.git
# 或者克隆 Gitee 的倉庫
git clone -b second-part https://gitee.com/tuture/covid-19-with-hooks.git
複製代碼

自定義 Hook 是 React Hooks 中最有趣的功能,或者說特點。簡單來講,它用一種高度靈活的方式,可以讓你在不一樣的函數組件之間共享某些特定的邏輯。咱們先來經過一個很是簡單的例子來看一下。github

一個簡單的自定義 Hook

先來看一個 Hook,名爲 useBodyScrollPosition ,用於獲取當前瀏覽器的垂直滾動位置:編程

function useBodyScrollPosition() {
  const [scrollPosition, setScrollPosition] = useState(null);

  useEffect(() => {
    const handleScroll = () => setScrollPosition(window.scrollY);
    document.addEventListener('scroll', handleScroll);
    return () =>
      document.removeEventListener('scroll', handleScroll);
  }, []);

  return scrollPosition;
}
複製代碼

經過觀察,咱們能夠發現自定義 Hook 具備如下特色:json

  • 表面上:一個命名格式爲 useXXX 的函數,但不是 React 函數式組件
  • 本質上:內部經過使用 React 自帶的一些 Hook (例如 useStateuseEffect )來實現某些通用的邏輯

若是你發散一下思惟,能夠想到有不少地方能夠去作自定義 Hook:DOM 反作用修改/監聽、動畫、請求、表單操做、數據存儲等等。數組

提示

這裏推薦兩個強大的 React Hooks 庫:React UseUmi Hooks。它們都實現了不少生產級別的自定義 Hook,很是值得學習。

我想這即是 React Hooks 最大的魅力——經過幾個內置的 Hook,你能夠按照某些約定進行任意組合,「製造出」任何你真正須要的 Hook,或者調用他人寫好的 Hook,從而輕鬆應對各類複雜的業務場景。就好像大千世界無奇不有,卻不過是由一百多種元素組合而成。

管窺自定義 Hook 背後的原理

又到了動畫時間。咱們來看看在組件初次渲染時的情形:

咱們在 App 組件中調用了 useCustomHook 鉤子。能夠看到,即使咱們切換到了自定義 Hook 中,Hook 鏈表的生成依舊沒有改變。再來看看重渲染的狀況:

一樣地,即使代碼的執行進入到自定義 Hook 中,咱們依然能夠從 Hook 鏈表中讀取到相應的數據,這個」配對「的過程總能成功。

咱們再次回味一下 Rules of Hook。它規定只有在兩個地方可以使用 React Hook:

  1. React 函數組件
  2. 自定義 Hook

第一點咱們早就清楚了,第二點經過剛纔的兩個動畫相信你也明白了:自定義 Hook 本質上只是把調用內置 Hook 的過程封裝成一個個能夠複用的函數,並不影響 Hook 鏈表的生成和讀取

實戰環節

讓咱們繼續 COVID-19 數據應用的開發。接下來,咱們打算實現歷史數據的展現,包括確診病例、死亡病例和治癒人數。

咱們首先來實現一個自定義 Hook,名爲 useCoronaAPI ,用於共享從 NovelCOVID 19 API 獲取數據的邏輯。建立 src/hooks/useCoronaAPI.js,填寫代碼以下:

import { useState, useEffect } from "react";

const BASE_URL = "https://corona.lmao.ninja";

export function useCoronaAPI( path, { initialData = null, converter = (data) => data, refetchInterval = null } ) {
  const [data, setData] = useState(initialData);

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(`${BASE_URL}${path}`);
      const data = await response.json();
      setData(converter(data));
    };
    fetchData();

    if (refetchInterval) {
      const intervalId = setInterval(fetchData, refetchInterval);
      return () => clearInterval(intervalId);
    }
  }, [converter, path, refetchInterval]);

  return data;
}
複製代碼

能夠看到,定義的 useCoronaAPI 包含兩個參數,第一個是 path ,也就是 API 路徑;第二是配置參數,包括如下參數:

  • initialData :初始爲空的默認數據
  • converter :對原始數據的轉換函數(默認是一個恆等函數)
  • refetchInterval :從新獲取數據的間隔(以毫秒爲單位)

此外,咱們還要注意 useEffect 所傳入的 deps 數組,包括了三個元素(都是在 Effect 函數中用到的):converterpathrefetchInterval ,均來自 useCoronaAPI 傳入的參數。

提示

上一篇文章中,咱們簡單地提到過,不要對 useEffect 的依賴說謊,那麼這裏就是一個很好的案例:咱們將 Effect 函數全部用到的外部數據(包括函數)所有加入到了依賴數組中。固然,因爲 BASE_URL 屬於模塊級別的常量,所以不須要做爲依賴。不過這裏留了個坑,嘿嘿……

而後在根組件 src/App.js 中使用剛剛建立的 useCoronaAPI 鉤子,代碼以下:

import React, { useState } from "react";

// ...
import { useCoronaAPI } from "./hooks/useCoronaAPI";

function App() {
  const globalStats = useCoronaAPI("/all", {
    initialData: {},
    refetchInterval: 5000,
  });

  const [key, setKey] = useState("cases");
  const countries = useCoronaAPI(`/countries?sort=${key}`, {
    initialData: [],
    converter: (data) => data.slice(0, 10),
  });

  return (
    // ...
  );
}

export default App;
複製代碼

整個 App 組件變得清晰了不少,不是嗎?

可是當咱們滿懷期待地把應用跑起來,卻發現整個應用陷入「無限請求」的怪圈中。打開 Chrome 開發者工具的 Network 選項卡,你會發現網絡請求數量始終在飆升……

嚇得咱們趕忙把網頁關了。冷靜下來以後,不由沉思:這究竟是爲何呢?

危險

NovelCOVID 19 API 屬於公益性質的數據資源,咱們應該儘快把頁面關掉,避免給對方的服務器形成太大的請求壓力。

useCallback:定海神針

若是你一字一句把上一篇文章看下來,其實可能已經發現了問題的線索:

依賴數組在判斷元素是否發生改變時使用了 Object.is 進行比較,所以當 deps 中某一元素爲非原始類型時(例如函數、對象等),每次渲染都會發生改變,從而每次都會觸發 Effect,失去了 deps 自己的意義。

OK,若是你沒有印象也不要緊,咱們先來聊一聊初學 React Hooks 常常會遇到的一個問題:Effect 無限循環。

關於 Effect 無限循環

來看一下這段」永不中止「的計數器:

function EndlessCounter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setTimeout(() => setCount(count + 1), 1000);
  });

  return (
    <div className="App"> <h1>{count}</h1> </div>
  );
}
複製代碼

若是你去運行這段代碼,會發現數字永遠在增加。咱們來經過一段動畫來演示一下這個」無限循環「究竟是怎麼回事:

咱們的組件陷入了:渲染 => 觸發 Effect => 修改狀態 => 觸發重渲染的無限循環。

想必你已經發現 useEffect 陷入無限循環的」罪魁禍首「了——由於沒有提供正確的 deps !從而致使每次渲染後都會去執行 Effect 函數。事實上,在以前的 useCoronaAPI 中,也是由於傳入的 deps 存在問題,致使每次渲染後都去執行 Effect 函數去獲取數據,陷入了無限循環。那麼,究竟是哪一個依賴出現了問題?

沒錯,就是那個 converter 函數!咱們知道,在 JavaScript 中,原始類型和非原始類型在判斷值是否相同的時候有巨大的差異:

// 原始類型
true === true // true
1 === 1 // true
'a' === 'a' // true

// 非原始類型
{} === {} // false
[] === [] // false
() => {} === () => {} // false
複製代碼

一樣,每次傳入的 converter 函數雖然形式上同樣,但仍然是不一樣的函數(引用不相等),從而致使每次都會執行 Effect 函數。

關於記憶化緩存(Memoization)

Memoization,通常稱爲記憶化緩存(或者「記憶」),聽上去是很高深的計算機專業術語,可是它背後的思想很簡單:假如咱們有一個計算量很大的純函數(給定相同的輸入,必定會獲得相同的輸出),那麼咱們在第一次遇到特定輸入的時候,把它的輸出結果「記」(緩存)下來,那麼下次碰到一樣的輸出,只須要從緩存裏面拿出來直接返回就能夠了,省去了計算的過程!

實際上,除了節省沒必要要的計算、從而提升程序性能以外,Memoization 還有一個用途:用了保證返回值的引用相等

咱們先經過一段簡單的求平方根的函數,熟悉一下 Memoization 的原理。首先是一個沒有緩存的版本:

function sqrt(arg) {
  return { result: Math.sqrt(arg) };
}
複製代碼

你也許注意到了咱們特意返回了一個對象來記錄結果,咱們後面會和 Memoized 的版本進行對比分析。而後是加了緩存的版本:

function memoizedSqrt(arg) {
  // 若是 cache 不存在,則初始化一個空對象
  if (!memoizedSqrt.cache) {
    memoizedSqrt.cache = {};
  }

  // 若是 cache 沒有命中,則先計算,再存入 cache,而後返回結果
  if (!memoizedSqrt.cache[arg]) {
    return memoizedSqrt.cache[arg] = { result: Math.sqrt(arg) };
  }

  // 直接返回 cache 內的結果,無需計算
  return memoizedSqrt.cache[arg];
}
複製代碼

而後咱們嘗試調用這兩個函數,就會發現一些明顯的區別:

sqrt(9)                      // { result: 3 }
sqrt(9) === sqrt(9)          // false
Object.is(sqrt(9), sqrt(9))  // false

memoizedSqrt(9)                              // { result: 3 }
memoizedSqrt(9) === memoizedSqrt(9)          // true
Object.is(memoizedSqrt(9), memoizedSqrt(9))  // true
複製代碼

普通的 sqrt 每次返回的結果的引用都不相同(或者說是一個全新的對象),而 memoizedSqrt 則能返回徹底相同的對象。所以在 React 中,經過 Memoization 能夠確保屢次渲染中的 Prop 或者狀態的引用相等,從而可以避免沒必要要的重渲染或者反作用執行。

讓咱們來總結一下記憶化緩存(Memoization)的兩個使用場景:

  • 經過緩存計算結果,節省費時的計算
  • 保證相同輸入下返回值的引用相等

使用方法和原理解析

爲了解決函數在屢次渲染中的引用相等(Referential Equality)問題,React 引入了一個重要的 Hook—— useCallback。官方文檔介紹的使用方法以下:

const memoizedCallback = useCallback(callback, deps);
複製代碼

第一個參數 callback 就是須要記憶的函數,第二個參數就是你們熟悉的 deps 參數,一樣也是一個依賴數組(有時候也被稱爲輸入 inputs)。在 Memoization 的上下文中,這個 deps 的做用至關於緩存中的鍵(Key),若是鍵沒有改變,那麼就直接返回緩存中的函數,而且確保是引用相同的函數。

在大多數狀況下,咱們都是傳入空數組 [] 做爲 deps 參數,這樣 useCallback 返回的就始終是同一個函數,永遠不會更新

提示

你也許在剛開始學習 useEffect 的時候就發現:咱們並不須要把 useState 返回的第二個 Setter 函數做爲 Effect 的依賴。實際上,React 內部已經對 Setter 函數作了 Memoization 處理,所以每次渲染拿到的 Setter 函數都是徹底同樣的,deps 加不加都是沒有影響的。

按照慣例,咱們仍是經過一段動畫來了解一下 useCallback 的原理(deps 爲空數組的狀況),首先是初次渲染:

和以前同樣,調用 useCallback 也是追加到 Hook 鏈表上,不過這裏着重強調了這個函數 f1 所指向的內存位置(隨便畫了一個),從而明確告訴咱們:這個 f1 始終是指向同一個函數。而後返回的 onClick 則是指向 Hook 中存儲的 f1

再來看看重渲染的狀況:

重渲染的時候,再次調用 useCallback 一樣返回給咱們 f1 函數,而且這個函數仍是指向同一塊內存,從而使得 onClick 函數和上次渲染時真正作到了引用相等

useCallback 和 useMemo 的關係

咱們知道 useCallback 有個好基友叫 useMemo。還記得咱們以前總結了 Memoization 的兩大場景嗎?useCallback 主要是爲了解決函數的」引用相等「問題,而 useMemo 則是一個」全能型選手「,可以同時勝任引用相等和節約計算的任務。

實際上,useMemo 的功能是 useCallback超集。與 useCallback 只能緩存函數相比,useMemo 能夠緩存任何類型的值(固然也包括函數)。useMemo 的使用方法以下:

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
複製代碼

其中第一個參數是一個函數,這個函數返回值的返回值(也就是上面 computeExpensiveValue 的結果)將返回給 memoizedValue 。所以如下兩個鉤子的使用是徹底等價的:

useCallback(fn, deps);
useMemo(() => fn, deps);
複製代碼

鑑於在前端開發中遇到的計算密集型任務是至關少的,並且瀏覽器引擎的性能也足夠優秀,所以這一系列文章不會深刻去講解 useMemo 的使用。更況且,已經掌握 useCallback 的你,應該也已經知道怎麼去使用 useMemo 了吧?

實戰環節

熟悉了 useCallback 以後,咱們開始修復 useCoronaAPI 鉤子的問題。修改 src/hooks/useCoronaAPI ,代碼以下:

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

// ...

export function useCoronaAPI( // ... ) {
  const [data, setData] = useState(initialData);
  const convertData = useCallback(converter, []);

  useEffect(() => {
    const fetchData = async () => {
      // ...
      setData(convertData(data));
    };
    fetchData();

    // ...
  }, [convertData, path, refetchInterval]);

  return data;
}
複製代碼

能夠看到,咱們把 converter 函數用 useCallback 包裹了起來,把記憶化處理後的函數命名爲 convertData,而且傳入的 deps 參數爲空數組 [] ,確保每次渲染都相同。而後把 useEffect 中全部的 converter 函數相應修改爲 convertData

最後再次開啓項目,一切又迴歸了正常,此次自定義 Hook 重構圓滿完成!在下一篇教程中,咱們將開始進一步推動 COVID-19 數據可視化項目的推動,經過曲線圖的方式實現歷史數據的展現(包括確診、死亡和治癒)。數據狀態變得愈來愈複雜,咱們又該如何應對呢?敬請期待。

劇透提醒:用 useReducer + useContext 實現一個簡單的 Redux!

參考資料

想要學習更多精彩的實戰技術教程?來圖雀社區逛逛吧。

相關文章
相關標籤/搜索