让我们面对它吧。反应式编程和传统的网络API并不是朋友。事件处理程序、观察者、维度,所有这些东西都是为了使用回调而必须使用的。这与反应式编程并不相称,会让我们的组件充满回调和额外的状态。你最后一次需要跟踪滚动位置、模糊一个字段或检查一个元素的大小是什么时候?我敢打赌,可能是最近。在这篇文章中,我们将看到我们如何利用我们所知道的反应式编程模式,并以这样一种方式应用它,我们可以减少和隐藏我们的回调,使之成为美妙的可重用的小钩子。
有许多DOM API,所以这将是一个多部分的系列。我们将从两个重磅炸弹开始:焦点和滚动。
免责声明:我们将在这篇文章中使用React,因为它是如此受欢迎,我们希望尽可能多地接触到观众,但这些相同的原则也适用于其他反应式框架(你有没有试过 Dojo?)
如何在React编程中用钩子减少或隐藏回调
焦点/模糊
情况是这样的。我们有一个组件,根据它是否在焦点上做出一些决定。
解决方案。通过创建一个自定义的钩子,我们可以从我们的组件中移除焦点跟踪,并创建一个可重复使用的解决方案来跟踪任何组件中的焦点。
比方说,我们有一个输入字段,当该字段处于焦点时显示一些额外的指导。类似这样的东西:
export const GuidedInput = () => {
const [value, setValue] = useState("");
const [focused, setFocused] = useState(false);
return (
<div>
<input
type="text"
value={value}
onInput={(ev) => setValue(ev.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
/>
{focused && <div>Format yyyy-mm-dd</div>}
</div>
);
};
这已经很简单了。但是如果我们想让更多的字段提供类似的体验,比如一个有指导的日期选择器,或者一个有指导的富文本区域呢?我们将不得不在每个组件中重复我们的焦点跟踪逻辑,增加额外的状态和测试的复杂性。
使用React钩子,我们可以把我们的状态捆绑在一个小钩子里:
const useFocus = (ref) => {
const [focused, setFocused] = useState(false);
// 1
useEffect(() => {
if (ref.current) {
const hasFocus = (n) =>
document.activeElement === n || n.contains(document.activeElement);
const node = ref.current;
// 2
const handler = () => {
if (hasFocus(node) && !focused) {
setFocused(true);
} else if (!hasFocus(node) && focused) {
setFocused(false);
}
};
// 3
node.addEventListener("focus", handler);
node.addEventListener("blur", handler);
// 4
return () => {
node.removeEventListener("focus", handler);
node.removeEventListener("blur", handler);
};
}
}, [ref, focused]);
return focused;
};
好的,这里发生了什么?让我们一步一步地看下去:
- 我们正在使用
useEffect,每当ref或focus的状态发生变化时,就运行一些代码。这将确保如果一个组件需要销毁和重新创建DOM节点(可能来自于一个键的变化),我们总是在处理正确的节点。 - 我们定义了一个焦点/模糊处理程序,它将在节点的焦点和模糊事件中被运行。就像在原来的例子中,我们只是根据节点是否处于焦点状态来更新我们的状态。
- 我们正在使用必要的DOM APIs来添加焦点和模糊事件监听器与我们的处理器。
- 我们要确保我们清理了我们的事件监听器!
实际上,我们已经把我们组件中处理DOM APIs的部分,捆绑在一个单一的钩子中。我们现在可以在任何我们想使用的组件中使用这个钩子,而不需要跟踪额外的状态这就是我们在所有的例子中要做的事情的关键。我们将把DOM API捆绑成方便的钩子,这样我们就不会在我们的组件中看到它们。
看一下我们更新的例子:
export const GuidedInput = () => {
const [value, setValue] = useState("");
const ref = useRef();
const focused = useFocus(ref);
return (
<div>
<input
ref={ref}
type="text"
value={value}
onInput={(ev) => setValue(ev.target.value)}
/>
{focused && <div>Format yyyy-mm-dd</div>}
</div>
);
};
看到这有多清楚了吗?我们不再关注(糟糕的双关语)如何跟踪焦点,而是只关注我们的组件在被关注或不被关注时做什么。
你可能已经注意到,我们的焦点处理程序所做的不仅仅是简单的 "这个元素是否被聚焦"。我们已经扩展了焦点处理,以确定我们的元素是否被关注,或者我们元素中的任何元素是否被关注。这个功能的扩展让我们可以创建更复杂的引导式输入,而不需要额外的努力。
滚动
情况是这样的。我们有一个组件需要知道另一个元素的滚动进度,无论是文档主体还是任何其他元素。
解决方案。我们可以将我们的滚动处理隐藏在一个自定义的钩子中,从而使我们的组件尽可能地被反应。
所以,我们有一个网站,有一个很酷的标题。一个非常时髦的、高大的标题,但我们希望标题上的导航在用户滚动过去时变得粘稠,所以它总是可用。
我们通过给文档添加一个事件监听器,比较文档的滚动位置和我们的粘性标题的顶部,并设置标题是否需要粘性的状态。看看这些必要的代码吧!
export default function App() {
const [fixedHeader, setFixedHeader] = useState(false);
useEffect(() => {
const scroller = () => {
const top = window.scrollY;
if (!fixedHeader && top >= 100) {
setFixedHeader(true);
} else if (fixedHeader && top < 100) {
setFixedHeader(false);
}
};
document.addEventListener("scroll", scroller);
return () => document.removeEventListener("scroll", scroller);
}, [fixedHeader]);
return (
<div className="App">
<header className={`header`}>
<h1>Title</h1>
<div class={`tabs ${fixedHeader ? "fixed" : ""}`}>Sticky Header</div>
</header>
{Array.from(Array(5)).map(() => (
<div className="content-block" />
))}
</div>
);
}
从我们的焦点例子中得到启发,我们知道我们可以把文档滚动监听器隐藏在另一个钩子里,只用滚动位置来决定我们的页眉。
考虑到这些目标,让我们来编写我们的新滚动钩子:
export const useScroll = (
domNode
) => {
// 1
const [scrollY, setScrollY] = useState(0);
// 2
useEffect(() => {
const scroller = () => {
setScrollY(domNode.scrollY || domNode.scrollTop);
};
domNode.addEventListener("scroll", scroller);
return () => domNode.removeEventListener("scroll", scroller);
}, [domNode]);
// 3
return scrollY;
};
让我们看一下细节:
- 我们在状态中存储滚动位置。这允许我们在滚动时重新渲染我们的组件,同时也可以访问最新记录的滚动位置。
- 与我们的焦点处理程序一样,我们使用
useEffect来注册/清理我们的事件监听器。这很简单,我们所要做的就是记录最后一个已知的滚动位置。 - 虽然这很简单,但这是整个钩子的关键所在。通过总是返回最新的滚动位置,我们可以在每次渲染时做出反应性的决定。
使用这个钩子,我们的固定页眉组件就更容易理解了:
export default function App() {
const fixedHeader = useScroll(window) >= 100;
return (
<div className="App">
<header className={`header`}>
<h1>Title</h1>
<div className={`tabs ${fixedHeader ? "fixed" : ""}`}>
Sticky Header
</div>
</header>
{Array.from(Array(5)).map((_, index) => (
<div key={`block-${index}`} className="content-block" />
))}
</div>
);
}
考虑一下测试这个问题是多么容易。我们简单地模拟useScroll ,让它返回两个不同的值来测试两个标题。这比我们上一个版本要容易得多,因为上一个版本需要模拟滚动事件并等待组件的更新。
我们在这里创建了一个可重复使用的钩子,但在我们的案例中,我认为我们可以更进一步。每次用户滚动时,我们的组件就会重新渲染,因为我们的钩子不知道它是根据什么业务规则运行的。在我们的例子中,这并不是什么大问题,它是如此简单,但在现实世界中,这可能会导致严重的性能问题。如果它不是返回一个滚动的位置,而是准确地告诉我们我们的组件是否需要一个固定的标题呢?
export const useFixedHeader = (domNode, threshold) => {
const [result, setResult] = useState(false);
useEffect(() => {
const scroller = () => {
const scrollTop = domNode.scrollY || domNode.scrollTop;
const latestResult = scrollTop >= threshold;
if (latestResult !== result) {
setResult(latestResult);
}
};
domNode.addEventListener("scroll", scroller);
scroller();
return () => domNode.removeEventListener("scroll", scroller);
}, [domNode, threshold, result]);
return result;
};
// ...
export default function App() {
const fixedHeader = useFixedHeader(window, 100); // wow!
return (
<div className="App">
<header className={`header`}>
<h1>Title</h1>
<div className={`tabs ${fixedHeader ? "fixed" : ""}`}>
Sticky Header
</div>
</header>
{Array.from(Array(5)).map((_, index) => (
<div key={`block-${index}`} className="content-block" />
))}
</div>
);
}
现在我们已经做到了除非标题需要改变,否则我们的组件不会重新渲染。这将确保我们为用户提供最顺畅的滚动体验。
总结
你可能已经明白了我们在解决这些问题时所遵循的模式。我们已经将DOM API回调提取为可重用的React钩子,我们可以将其插入其他组件中。这些帮助我们产生反应式组件,这些组件只做我们关心的决定,而不是涉足DOM API访问的所有残余部分。
我们已经经历了两个减少回调的例子,但这些同样的原则也可以应用于其他API。在本系列的下一篇文章中,我们将看一下我们最喜欢的另外两个API。交叉观察者和元素尺寸。敬请关注☮️