自定义 React Hook 是一个必要的工具,它可以让你为 React 应用程序添加特殊的、独特的功能。
在许多情况下,如果你想向应用程序添加特定的特性,您可以简单地安装一个第三方库来解决您的问题。但如果这样的库或钩子不存在,该怎么办?
作为 React 开发人员,学习如何创建自定义钩子来解决问题或在自己的 React 项目中添加缺失的特性是很重要的。
在这个循序渐进的指南中,我将通过分解我为自己的应用程序创建的三个钩子,以及创建这些钩子是为了解决什么问题,向您展示如何创建自己的自定义React钩子。
在我以前的网站上,我允许用户在一个名为 react-copy-to-clipboard
的包的帮助下从我的文章中复制代码。
用户只需将鼠标悬停在代码片段上,单击剪贴板按钮,代码就会被添加到他们电脑的剪贴板中,以便他们可以在任何他们想要的地方粘贴和使用代码。
然而,我不想使用第三方库,而是想用自己的自定义 React 钩子重新创建这个功能。对于我创建的每个自定义 react 钩子,我都把它放在一个专门的文件夹中,通常称为 utils 或 lib,专门用于我可以在应用程序中重用的函数。
我们将把这个钩子放到一个名为 useCopyToClipboard.js 的文件中,并创建一个同名的函数。
我们有多种方法可以将一些文本复制到用户的剪贴板。我更喜欢使用一个库来实现这一点,这个库使这个过程更加可靠,这个库叫做“复制到剪贴板”。
它导出一个函数,我们将其称为copy。
// utils/useCopyToClipboard.js
import React from "react";
import copy from "copy-to-clipboard";
export default function useCopyToClipboard() {}
接下来,我们将创建一个函数,用于复制想要添加到用户剪贴板的任何文本。我们将调用这个函数 handleCopy。
在这个函数中,我们首先需要确保它只接受字符串或数字类型的数据。我们将建立一个 if-else 语句,它将确保类型是字符串或数字。否则,我们将在控制台 log 一个 error,告诉用户不能复制任何其他类型。
import React from "react";
import copy from "copy-to-clipboard";
export default function useCopyToClipboard() {
const [isCopied, setCopied] = React.useState(false);
function handleCopy(text) {
if (typeof text === "string" || typeof text == "number") {
// 复制
} else {
// 不复制
console.error(
`Cannot copy typeof ${typeof text} to clipboard, must be a string or number.`
);
}
}
}
接下来,我们获取文本并将其转换为字符串,然后将其传递给 copy 函数。从那里,我们将 handleCopy 函数从钩子返回到应用程序中我们想要的任何地方。
通常,handleCopy函数会连接到一个按钮的onClick。
import React from "react";
import copy from "copy-to-clipboard";
export default function useCopyToClipboard() {
function handleCopy(text) {
if (typeof text === "string" || typeof text == "number") {
copy(text.toString());
} else {
console.error(
`Cannot copy typeof ${typeof text} to clipboard, must be a string or number.`
);
}
}
return handleCopy;
}
此外,我们需要一些表示文本是否被复制的状态。为了创建它,我们将在钩子顶部调用 useState
,并创建一个新的状态变量 iscopy
,其中的 setter
将被称为 setCopy
。
最初这个值是假的。如果文本成功复制,我们将把 copy
设置为 true
。否则,我们将它设置为 false
。
最后,在数组中返回 isreplicate from the hook with handleCopy
。
import React from "react";
import copy from "copy-to-clipboard";
export default function useCopyToClipboard(resetInterval = null) {
const [isCopied, setCopied] = React.useState(false);
function handleCopy(text) {
if (typeof text === "string" || typeof text == "number") {
copy(text.toString());
setCopied(true);
} else {
setCopied(false);
console.error(
`Cannot copy typeof ${typeof text} to clipboard, must be a string or number.`
);
}
}
return [isCopied, handleCopy];
}
我们现在可以在任何我们喜欢的组件中使用 useCopyToClipboard。
在我的例子中,我将使用它与一个复制按钮组件,它接收我们的代码片段的代码。
要做到这一点,我们需要做的就是向按钮添加一个onclick
。并在返回一个名为handle
的函数时,将被请求的代码复制为文本。一旦复制成功,就是真的了。我们可以显示一个不同的图标,表明复制成功。
import React from "react";
import ClipboardIcon from "../svg/ClipboardIcon";
import SuccessIcon from "../svg/SuccessIcon";
import useCopyToClipboard from "../utils/useCopyToClipboard";
function CopyButton({ code }) {
const [isCopied, handleCopy] = useCopyToClipboard();
return (
<button onClick={() => handleCopy(code)}>
{isCopied ? <SuccessIcon /> : <ClipboardIcon />}
</button>
);
}
我们可以对代码做一个改进。就像我们现在所编写的钩子一样,iscopy总是正确的,这意味着我们总是能够看到成功图标。
如果我们想在几秒钟后重置我们的状态,你可以传递一个时间间隔给useCopyToClipboard。让我们添加这个功能。
回到我们的钩子中,我们可以创建一个名为 resetInterval
的形参,它的默认值为null
,这将确保在没有参数传递给它的情况下状态不会重置。
然后,我们添加 useEffect
,说明如果文本被复制,并且我们有一个重置间隔,我们将在这个间隔之后使用 setTimeout
将 isCopied
设为false
。
此外,如果钩子所使用的组件正在卸载(这意味着我们的状态不再需要更新),我们需要清除这个超时。
import React from "react";
import copy from "copy-to-clipboard";
export default function useCopyToClipboard(resetInterval = null) {
const [isCopied, setCopied] = React.useState(false);
const handleCopy = React.useCallback((text) => {
if (typeof text === "string" || typeof text == "number") {
copy(text.toString());
setCopied(true);
} else {
setCopied(false);
console.error(
`Cannot copy typeof ${typeof text} to clipboard, must be a string or number.`
);
}
}, []);
React.useEffect(() => {
let timeout;
if (isCopied && resetInterval) {
timeout = setTimeout(() => setCopied(false), resetInterval);
}
return () => {
clearTimeout(timeout);
};
}, [isCopied, resetInterval]);
return [isCopied, handleCopy];
}
最后,我们可以做的最后一个改进是将 handleCopy
包装在useCallback
钩子中,以确保它不会在每次有重新渲染时被重新创建。
有了那个,我们有了我们的最终钩子它允许状态在给定的时间间隔后被重置。如果我们传递一个给它,我们应该看到如下所示的结果。
import React from "react";
import ClipboardIcon from "../svg/ClipboardIcon";
import SuccessIcon from "../svg/SuccessIcon";
import useCopyToClipboard from "../utils/useCopyToClipboard";
function CopyButton({ code }) {
// isCopied 在3秒超时后被重置
const [isCopied, handleCopy] = useCopyToClipboard(3000);
return (
<button onClick={() => handleCopy(code)}>
{isCopied ? <SuccessIcon /> : <ClipboardIcon />}
</button>
);
}
在 React 应用中,有时了解用户何时滚动到页面底部是很重要的。
在你可以无限滚动的应用中,比如微博,一旦用户点击页面底部,你就需要获取更多的帖子。
让我们看看如何自己创建一个 usePageBottom
钩子,用于类似的用例,比如创建无限滚动。
我们将从创建一个单独的文件usePageBottom
开始。我们将在我们的utils文件夹中添加一个同名的函数(hook):
// utils/usePageBottom.js
import React from "react";
export default function usePageBottom() {}
接下来,我们需要计算用户何时到达页面底部。我们可以通过窗口的信息来确定。为了访问它,我们需要确保钩子在内部被调用的组件被挂载,所以我们将使用一个空的dependencies
数组的useEffect
钩子。
// utils/usePageBottom.js
import React from "react";
export default function usePageBottom() {
React.useEffect(() => {}, []);
}
当窗口的innerHeight
值加上文档的scrollTop
值等于offsetHeight
值时,用户将滚动到页面的底部。如果这两个值相等,结果将为真,并且用户已经滚动到页面底部:
// utils/usePageBottom.js
import React from "react";
export default function usePageBottom() {
React.useEffect(() => {
window.innerHeight + document.documentElement.scrollTop ===
document.documentElement.offsetHeight;
}, []);
}
我们将把这个表达式的结果存储在变量isBottom
中,并更新一个名为bottom
的状态变量,这个状态变量最终会从钩子中返回。
// utils/usePageBottom.js
import React from "react";
export default function usePageBottom() {
const [bottom, setBottom] = React.useState(false);
React.useEffect(() => {
const isBottom =
window.innerHeight + document.documentElement.scrollTop ===
document.documentElement.offsetHeight;
setBottom(isButton);
}, []);
return bottom;
}
然而,我们的代码不能正常工作。为什么不呢?
问题在于,当用户滚动时,我们需要计算isBottom
。因此,我们需要使用window.addEventListener
监听滚动事件。我们可以通过创建一个本地函数来重新计算这个表达式,该函数在用户滚动时被调用,称为handleScroll
。
// utils/usePageBottom.js
import React from "react";
export default function usePageBottom() {
const [bottom, setBottom] = React.useState(false);
React.useEffect(() => {
function handleScroll() {
const isBottom =
window.innerHeight + document.documentElement.scrollTop
=== document.documentElement.offsetHeight;
setBottom(isButton);
}
window.addEventListener("scroll", handleScroll);
}, []);
return bottom;
}
最后,因为我们有一个正在更新状态的事件侦听器,所以我们需要处理用户从页面导航离开和组件被删除的事件。我们需要删除添加的滚动事件监听器,这样就不会尝试更新不再存在的状态变量。
我们可以通过从useEffect
和window
返回一个函数来实现这一点。removeEventListener
,其中我们传递了对同一个handleScroll
函数的引用。
// utils/usePageBottom.js
import React from "react";
export default function usePageBottom() {
const [bottom, setBottom] = React.useState(false);
React.useEffect(() => {
function handleScroll() {
const isBottom =
window.innerHeight + document.documentElement.scrollTop
=== document.documentElement.offsetHeight;
setBottom(isButton);
}
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
return bottom;
}
现在,我们可以在任何想知道是否已经到达页面底部的函数中简单地调用这段代码。
为此,我们可以使用一个媒体查询(CSS),或者使用一个自定义的React钩子来提供当前页面的大小,并隐藏或显示JSX中的链接。
以前,我使用的是一个名为react-use
的库中的钩子。我决定创建自己的钩子来提供窗口的尺寸,包括宽度和高度,而不是引入整个第三方库。我把这个钩子叫做useWindowSize
。
首先,我们将在utils文件夹中创建一个新的.js文件,与钩子useWindowSize
同名。我将在导出自定义钩子的同时导入React(以使用钩子)。
// utils/useWindowSize.js
import React from "react";
export default function useWindowSize() {}
为了检查并确保我们不在服务器上,我们可以查看type of window是否等于字符串undefined。
在这种情况下,我们可以为浏览器返回默认的宽度和高度,例如,在一个对象内1200和800:
// utils/useWindowSize.js
import React from "react";
export default function useWindowSize() {
if (typeof window !== "undefined") {
return { width: 1200, height: 800 };
}
}
假设我们在客户端并且可以获得窗口,我们可以使用useEffect
钩子通过与窗口交互来执行一个副作用。我们将包含一个空的dependencies
数组,以确保effect
函数只在组件(调用这个钩子的组件)挂载之后才被调用。
为了找出窗口的宽度和高度,我们可以添加一个事件监听器来监听resize
事件。当浏览器大小改变时,我们可以更新一块状态(用useState
创建),我们将其称为windowSize
,更新它的setter
将是setWindowSize
。
// utils/useWindowSize.js
import React from "react";
export default function useWindowSize() {
if (typeof window !== "undefined") {
return { width: 1200, height: 800 };
}
const [windowSize, setWindowSize] = React.useState();
React.useEffect(() => {
window.addEventListener("resize", () => {
setWindowSize({ width: window.innerWidth, height: window.innerHeight });
});
}, []);
}
当窗口调整大小时,回调函数将被调用,windowSize状态将根据当前窗口尺寸更新。为了得到它,我们设置width=window.innerWidth
, height=window.innerHeight
。
然而,我们这里的代码将不能工作。这是因为hook的一个关键规则是不能有条件地调用它们。因此,在useState
或useEffect
钩子被调用之前,不能有一个条件钩子。
为了解决这个问题,我们将有条件地设置useState
的初始值。我们将创建一个名为isSSR
的变量,它将执行相同的检查,以查看窗口是否等于未定义的字符串。
我们将使用三元值来设置宽度和高度首先检查我们是否在服务器上。如果是,则使用默认值,如果不是,则使用window.innerWidth window.innerHeight
。
// utils/useWindowSize.js
import React from "react";
export default function useWindowSize() {
const isSSR = typeof window !== "undefined";
const [windowSize, setWindowSize] = React.useState({
width: isSSR ? 1200 : window.innerWidth,
height: isSSR ? 800 : window.innerHeight,
});
React.useEffect(() => {
window.addEventListener("resize", () => {
setWindowSize({ width: window.innerWidth, height: window.innerHeight });
});
}, []);
}
最后,我们需要考虑组件何时卸载。我们需要做什么?我们需要删除调整大小监听器。
你可以通过从useEffect
返回一个函数来做到这一点。我们将使用window.removeEventListener
删除侦听器。
// utils/useWindowSize.js
import React from "react";
export default function useWindowSize() {
const isSSR = typeof window !== "undefined";
const [windowSize, setWindowSize] = React.useState({
width: isSSR ? 1200 : window.innerWidth,
height: isSSR ? 800 : window.innerHeight,
});
React.useEffect(() => {
window.addEventListener("resize", () => {
setWindowSize({ width: window.innerWidth, height: window.innerHeight });
});
return () => {
window.removeEventListener("resize", () => {
setWindowSize({ width: window.innerWidth, height: window.innerHeight });
});
};
}, []);
}
但是我们需要一个对同一个函数的引用,而不是两个不同的函数。为此,我们将为这两个监听器创建一个名为changeWindowSize
的共享回调函数。
最后,在钩子的末尾,我们将返回我们的windowSize
状态。
// utils/useWindowSize.js
import React from "react";
export default function useWindowSize() {
const isSSR = typeof window !== "undefined";
const [windowSize, setWindowSize] = React.useState({
width: isSSR ? 1200 : window.innerWidth,
height: isSSR ? 800 : window.innerHeight,
});
function changeWindowSize() {
setWindowSize({ width: window.innerWidth, height: window.innerHeight });
}
React.useEffect(() => {
window.addEventListener("resize", changeWindowSize);
return () => {
window.removeEventListener("resize", changeWindowSize);
};
}, []);
return windowSize;
}
要使用钩子,我们只需要在需要的地方导入它,调用它,并在想要隐藏或显示某些元素的地方使用宽度。
在我的例子中,这是500px标记。在那里,我想隐藏所有其他链接,只显示Join Now按钮,就像你在上面的例子中看到的:
// components/StickyHeader.js
import React from "react";
import useWindowSize from "../utils/useWindowSize";
function StickyHeader() {
const { width } = useWindowSize();
return (
<div>
{/* 仅当窗口大于500px时可见 */}
{width > 500 && (
<>
<div onClick={onTestimonialsClick} role="button">
<span>奖状</span>
</div>
<div onClick={onPriceClick} role="button">
<span>价格</span>
</div>
<div>
<span onClick={onQuestionClick} role="button">
问题?
</span>
</div>
</>
)}
{/* 在任何大小的窗口可见 */}
<div>
<span className="primary-button" onClick={onPriceClick} role="button">
现在加入
</span>
</div>
</div>
);
}
这个钩子将在任何服务器渲染的React应用程序上工作,比如 Next.js。
我正在构建一个新的登录页面时,我在移动设备上经历了一个非常奇怪的错误。在台式电脑上,这些样式看起来很棒。
但当我着眼于移动平台时,我发现所有内容都是不合适的,并且都是破碎的。
我追踪这个问题到一个名为react-device-detect
的库,我用它来检测用户是否有移动设备。如果是,我将删除标题。
// templates/course.js
import React from "react";
import { isMobile } from "react-device-detect";
function Course() {
return (
<>
<SEO />
{!isMobile && <StickyHeader {...courseData} />}
{/* more components... */}
</>
);
}
问题是这个库不支持服务器端呈现,而这是默认使用的。所以我需要创建自己的解决方案来检查用户何时使用移动设备。为此,我决定创建一个名为useDeviceDetect
的自定义钩子。
我在我的utils文件夹中用相同的名字为这个钩子创建了一个单独的文件useDeviceDetect.js
。因为钩子只是可共享的JavaScript函数,它利用React钩子,所以我创建了一个名为useDeviceDetect
的函数并导入了React。
// utils/useDeviceDetect.js
import React from "react";
export default function useDeviceDetect() {}
我们可以确定是否可以获得关于用户设备的信息的方法是通过userAgent
属性(位于window
的navigator
属性上)。
由于与作为API /外部资源的窗口API交互将被归类为副作用,所以我们需要访问useEffect
钩子中的用户代理。
// utils/useDeviceDetect.js
import React from "react";
export default function useDeviceDetect() {
React.useEffect(() => {
console.log(`user's device is: ${window.navigator.userAgent}`);
// 也可以写成'navigator.userAgent'
}, []);
}
一旦组件安装完毕,我们就可以使用typeof navigator
来确定我们是在客户机上还是在服务器上。如果我们在服务器上,我们就无法进入窗口。typeof navigator
将等于未定义的字符串,因为它不存在。否则,如果我们在客户机上,我们将能够获得我们的用户代理属性。
我们可以使用三元制来表示所有这些,以获得userAgent数据:
// utils/useDeviceDetect.js
import React from "react";
export default function useDeviceDetect() {
React.useEffect(() => {
const userAgent =
typeof navigator === "undefined" ? "" : navigator.userAgent;
}, []);
}
userAgent是一个字符串值,如果使用移动设备,它将被设置为以下设备名中的任何一个:
Android,iPhone, iPad, iPod, Opera Mini, imobile,或WPDesktop。
我们所要做的就是获取我们得到的字符串,并使用.match()
方法和一个regex
来查看它是否是这些字符串中的任何一个。我们将它存储在一个叫做mobile的局部变量中。
我们将结果存储在useState
钩子的状态中,并将初始值赋给它false。对于它,我们将创建一个相应的状态变量isMobile, setter将是setMobile。
// utils/useDeviceDetect.js
import React from "react";
export default function useDeviceDetect() {
const [isMobile, setMobile] = React.useState(false);
React.useEffect(() => {
const userAgent =
typeof window.navigator === "undefined" ? "" : navigator.userAgent;
const mobile = Boolean(
userAgent.match(
/Android|BlackBerry|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i
)
);
setMobile(mobile);
}, []);
}
一旦我们获得了移动值我们就会将它设置为状态。最后,我们将从该钩子返回一个对象,这样如果我们想给该钩子添加更多的功能,就可以在将来添加更多的值。
在对象中,我们将添加isMobile作为属性和值:
// utils/useDeviceDetect.js
import React from "react";
export default function useDeviceDetect() {
const [isMobile, setMobile] = React.useState(false);
React.useEffect(() => {
const userAgent =
typeof window.navigator === "undefined" ? "" : navigator.userAgent;
const mobile = Boolean(
userAgent.match(
/Android|BlackBerry|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i
)
);
setMobile(mobile);
}, []);
return { isMobile };
}
回到登录页,我们可以执行钩子,并从解构对象中获得该属性,并在需要的地方使用它。
// templates/course.js
import React from "react";
import useDeviceDetect from "../utils/useDeviceDetect";
function Course() {
const { isMobile } = useDeviceDetect();
return (
<>
<SEO />
{!isMobile && <StickyHeader {...courseData} />}
{/* more components... */}
</>
);
}
正如我试图通过这些示例说明的那样,定制React钩子可以为我们提供在第三方库不足时修复我们自己问题的工具。
我希望能让您更好地了解何时以及如何创建自己的React钩子。您可以在自己的项目中随意使用这些钩子和上面的代码,并以此为灵感创建自己的自定义React钩子。