本文由图雀社区成员 mRc 写作而成,欢迎加入图雀社区,一起创作精彩的免费技术教程,予力编程行业发展。
如果您觉得我们写得还不错,记得 点赞 + 关注 + 评论 三连,鼓励我们写出更好的教程💪
在第二篇教程中,我们将手把手带你用自定义 Hook 重构之前的组件代码,让它变得更清晰、并且可以实现逻辑复用。在重构完成之后,我们陷入了组件“不断获取数据并重新渲染”的无限循环,这时候,useCallback 站了出来,如同定海神针一般拯救了我们的应用……
欢迎访问本项目的 GitHub 仓库和 Gitee 仓库。
在上一篇教程中,我们通过动画的方式不断深入 useState
和 useEffect
,基本上理清了 React Hooks 背后的实现机制——链表,同时也实现了 COVID-19 数据可视化应用的全球数据总览和多个国家数据的直方图。
如果你想直接从这一篇教程开始阅读和实践,可下载本教程的源码:
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 中最有趣的功能,或者说特色。简单来说,它用一种高度灵活的方式,能够让你在不同的函数组件之间共享某些特定的逻辑。我们先来通过一个非常简单的例子来看一下。
先来看一个 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 具有以下特点:
useXXX
的函数,但不是 React 函数式组件useState
和 useEffect
)来实现某些通用的逻辑如果你发散一下思维,可以想到有很多地方可以去做自定义 Hook:DOM 副作用修改/监听、动画、请求、表单操作、数据存储等等。
提示
这里推荐两个强大的 React Hooks 库:React Use 和 Umi Hooks。它们都实现了很多生产级别的自定义 Hook,非常值得学习。
我想这便是 React Hooks 最大的魅力——通过几个内置的 Hook,你可以按照某些约定进行任意组合,“制造出”任何你真正需要的 Hook,或者调用他人写好的 Hook,从而轻松应对各种复杂的业务场景。就好像大千世界无奇不有,却不过是由一百多种元素组合而成。
又到了动画时间。我们来看看在组件初次渲染时的情形:
我们在 App
组件中调用了 useCustomHook
钩子。可以看到,即便我们切换到了自定义 Hook 中,Hook 链表的生成依旧没有改变。再来看看重渲染的情况:
同样地,即便代码的执行进入到自定义 Hook 中,我们依然可以从 Hook 链表中读取到相应的数据,这个”配对“的过程总能成功。
我们再次回味一下 Rules of Hook。它规定只有在两个地方能够使用 React 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 函数中用到的):converter
、path
和 refetchInterval
,均来自 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 属于公益性质的数据资源,我们应该尽快把页面关掉,避免给对方的服务器造成太大的请求压力。
如果你一字一句把上一篇文章看下来,其实可能已经发现了问题的线索:
依赖数组在判断元素是否发生改变时使用了
Object.is
进行比较,因此当deps
中某一元素为非原始类型时(例如函数、对象等),每次渲染都会发生改变,从而每次都会触发 Effect,失去了deps
本身的意义。
OK,如果你没有印象也没关系,我们先来聊一聊初学 React Hooks 经常会遇到的一个问题: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 的原理。首先是一个没有缓存的版本:
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
。还记得我们之前总结了 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!
想要学习更多精彩的实战技术教程?来图雀社区逛逛吧。