如何在 React 中不失理智地进行去抖动和节流
最初发表于https://www.developerway.com。该网站还有更多类似的文章 😉
说到性能,尤其是在 React 中,“立即”、“快速”、“尽快”这些词总是会浮现在脑海中。但这总是正确的吗?与普遍的看法相反,有时放慢脚步思考生活其实是件好事。稳扎稳打才能赢得比赛,你知道的 😉
你最不希望看到的就是异步搜索功能会因为用户输入速度过快而导致 Web 服务器崩溃,因为你每次按键都会发送请求。或者你的应用在滚动时变得无响应,甚至浏览器窗口崩溃,因为你在每次触发滚动事件时都会进行昂贵的计算(每秒可能有 30-100 次这样的计算!)。
这时,“节流”和“防抖”等“减速”技术就派上用场了。让我们简单了解一下它们是什么(如果你还没听说过的话),然后重点讲解如何在 React 中正确使用它们——这里有一些很多人不知道的注意事项!
附注:我将使用lodash库的debounce和throttle函数。本文中描述的技巧和注意事项适用于任何库或实现,即使您决定自己实现它们。
什么是防抖和节流
去抖动和节流是一种技术,如果某个函数在一定时间段内被调用的次数过多,我们可以跳过该函数的执行。
例如,假设我们正在实现一个简单的异步搜索功能:一个输入字段,用户可以在其中输入内容,输入的文本会被发送到后端,后端会返回相关的搜索结果。我们可以很“天真”地实现它,只需要一个输入字段和一个onChange
回调函数:
const Input = () => {
const onChange = (e) => {
// send data from input field to the backend here
// will be triggered on every keystroke
}
return <input onChange={onChange} />
}
但是一个熟练的打字员每分钟可以打 70 个字,大约相当于每秒按 6 次键。在这个实现中,它将导致 6 个onChange
事件,即每秒向服务器发出 6 个请求!你的后端确定能处理吗?
我们不必每次按键都发送请求,而是可以等到用户停止输入后再一次性发送所有值。这就是去抖动 (debounce) 的作用。如果我将去抖动 (debounce) 应用于onChange
函数,它会检测到我每次调用该函数的尝试,如果等待间隔尚未结束,它就会丢弃之前的调用并重新启动“等待”时钟。
const Input = () => {
const onChange = (e) => {
// send data from input field to the backend here
// will be triggered 500 ms after the user stopped typing
}
const debouncedOnChange = debounce(onChange, 500);
return <input onChange={debouncedOnChange} />
}
以前,如果我在搜索栏中输入“React”,每次按键都会立即向后端发送请求,值分别为“R”、“Re”、“Rea”、“Reac”、“React”。现在,在我设置了去抖动之后,它会在我停止输入“React”后等待 500 毫秒,然后只发送一个值为“React”的请求。
底层debounce
只是一个函数,它接受一个函数作为参数,返回另一个函数,并且内部有一个跟踪器,用于检测传入的函数是否在指定的时间间隔之前被调用。如果更早,则跳过执行并重新启动时钟。如果超过了指定的时间间隔,则调用传入的函数。本质上它是这样的:
const debounce = (callback, wait) => {
// initialize the timer
let timer;
...
// lots of code involving the actual implementation of timer
// to track the time passed since the last callback call
...
const debouncedFunc = () => {
// checking whether the waiting time has passed
if (shouldCallCallback(Date.now())) {
callback();
} else {
// if time hasn't passed yet, restart the timer
timer = startTimer(callback);
}
}
return debouncedFunc;
}
实际实现当然要复杂一些,你可以查看 lodash debounce 代码来了解它。
Throttle
非常相似,保留内部跟踪器和返回函数的函数的思路也相同。区别在于,前者保证每隔一段时间throttle
就定期调用回调函数,而后者则会不断重置计时器并等到结束。wait
debounce
如果我们不使用异步搜索示例,而是使用具有自动保存功能的编辑字段,差异将显而易见:如果用户在字段中输入内容,我们希望向后端发送请求,以便“即时”保存他们输入的内容,而无需用户明确按下“保存”按钮。如果用户在这样的字段中快速写诗,“debounced”onChange
回调只会触发一次。如果在输入过程中出现问题,整首诗都会丢失。“Throttled”回调将定期触发,诗歌将被定期保存,如果发生灾难,诗歌只会在最后几毫秒丢失。这种方法要安全得多。
您可以在此示例中尝试使用“正常”输入、去抖动输入和节流输入字段:
React 中的去抖动回调:处理重新渲染
现在,我们对 debounce 和 throttle 是什么、为什么需要它们以及如何实现它们有了更清晰的了解,是时候深入研究如何在 React 中使用它们了。希望你现在不会觉得“拜托,这有多难,不就是个函数嘛”,对吧?我们讨论的是 React,它以前真的那么简单吗?😅
首先,让我们仔细看看Input
具有去抖动onChange
回调的实现(从现在开始我将仅debounce
在所有示例中使用它,描述的每个概念也与节流阀相关)。
const Input = () => {
const onChange = (e) => {
// send data from input to the backend here
}
const debouncedOnChange = debounce(onChange, 500);
return <input onChange={debouncedOnChange} />
}
虽然这个示例运行完美,看起来像是一段没有任何警告的常规 React 代码,但不幸的是,它与实际生活毫无关联。在实际生活中,除了将输入值发送到后端之外,你很可能还会想对它进行一些处理。也许这个输入值会是一个大型表单的一部分。或者你想在那里添加一个“清除”按钮。又或者,这个input
标签实际上是某个外部库的组件,它会强制要求输入该value
字段。
我想说的是,有时你会希望将该值保存到状态中,要么保存在Input
组件本身,要么将其传递给父组件/外部状态管理组件来管理。Input
为了简单起见,我们先在 中进行。
const Input = () => {
// adding state for the value
const [value, setValue] = useState();
const onChange = (e) => {};
const debouncedOnChange = debounce(onChange, 500);
// turning input into controlled component by passing value from state there
return <input onChange={debouncedOnChange} value={value} />
}
value
我通过钩子添加了状态useState
,并将该值传递给了input
字段。剩下一件事就是在回调input
中更新该状态onChange
,否则输入将无法正常工作。通常情况下,如果没有去抖动,更新操作会在onChange
回调中完成:
const Input = () => {
const [value, setValue] = useState();
const onChange = (e) => {
// set state value from onChange event
setValue(e.target.value);
};
return <input onChange={onChange} value={value} />
}
我无法做到这一点,因为onChange
它是去抖动的:它的调用根据定义是延迟的,因此value
状态不会及时更新,并且input
根本不起作用。
const Input = () => {
const [value, setValue] = useState();
const onChange = (e) => {
// just won't work, this callback is debounced
setValue(e.target.value);
};
const debouncedOnChange = debounce(onChange, 500);
return <input onChange={debouncedOnChange} value={value} />
}
当调用它自己的时,我必须setValue
立即调用。这意味着我无法再对整个函数进行去抖动,而只能对真正需要减慢速度的部分进行去抖动:即向后端发送请求。input
onChange
onChange
大概是这样的吧?
const Input = () => {
const [value, setValue] = useState();
const sendRequest = (value) => {
// send value to the backend
};
// now send request is debounced
const debouncedSendRequest = debounce(sendRequest, 500);
// onChange is not debounced anymore, it just calls debounced function
const onChange = (e) => {
const value = e.target.value;
// state is updated on every value change, so input will work
setValue(value);
// call debounced request here
debouncedSendRequest(value);
}
return <input onChange={onChange} value={value} />
}
看似合乎逻辑。只是……它根本不起作用!现在请求根本没有去抖动,只是稍微延迟了一点。如果我在这个字段中输入“React”,我仍然会发送所有“R”、“Re”、“Rea”、“Reac”、“React”请求,而不是像正确去抖动功能那样只发送一个“React”,只是延迟了半秒而已。
看看这两个例子,自己想想吧。你能明白为什么吗?
答案当然是重新渲染(在 React 中通常如此😅)。众所周知,组件重新渲染的主要原因之一是状态变化。随着状态管理值的引入,我们现在会Input
在每次按键时重新渲染整个组件。因此,每次按键时,我们都会调用实际的debounce
函数,而不仅仅是去抖动回调。并且,正如我们在上一章中所知,debounce
调用该函数时,其含义如下:
- 创建新的计时器
- 创建并返回一个函数,当计时器完成时,将调用该函数内部传递的回调
因此,每次调用 重新渲染时debounce(sendRequest, 500)
,我们都会重新创建所有内容:新的调用、新的计时器、带有回调参数的新返回函数。但旧函数永远不会被清理,所以它只是停留在内存中,等待它自己的计时器结束。当计时器完成后,它会触发回调函数,然后就死了,最终被垃圾收集器清理掉。
我们最终得到的只是一个简单的delay
函数,而不是一个正式的函数debounce
。现在修复它的方法应该很明显了:我们应该debounce(sendRequest, 500)
只调用一次,以保留内部计时器和返回的函数。
最简单的方法就是将其移出Input
组件:
const sendRequest = (value) => {
// send value to the backend
};
const debouncedSendRequest = debounce(sendRequest, 500);
const Input = () => {
const [value, setValue] = useState();
const onChange = (e) => {
const value = e.target.value;
setValue(value);
// debouncedSendRequest is created once, so state caused re-renders won't affect it anymore
debouncedSendRequest(value);
}
return <input onChange={onChange} value={value} />
}
然而,如果这些函数依赖于组件生命周期中正在发生的事情(例如 state 或 props),那么这将不起作用。不过没问题,我们可以使用 memoization hooks 来实现完全相同的结果:
const Input = () => {
const [value, setValue] = useState("initial");
// memoize the callback with useCallback
// we need it since it's a dependency in useMemo below
const sendRequest = useCallback((value: string) => {
console.log("Changed value:", value);
}, []);
// memoize the debounce call with useMemo
const debouncedSendRequest = useMemo(() => {
return debounce(sendRequest, 1000);
}, [sendRequest]);
const onChange = (e) => {
const value = e.target.value;
setValue(value);
debouncedSendRequest(value);
};
return <input onChange={onChange} value={value} />;
}
以下是示例:
现在一切都按预期运行了!Input
组件有状态了,后端调用onChange
已去抖,而且去抖实际上运行正常🎉
直到它不再...
React 中的去抖动回调:处理内部状态
现在到了弹跳谜题的最后一部分。我们来看看这段代码:
const sendRequest = useCallback((value: string) => {
console.log("Changed value:", value);
}, []);
普通的 memoized 函数,接受value
一个参数并对其进行处理。该值直接来自input
debounce 函数。我们在回调中调用 debounced 函数时会传递它onChange
:
const onChange = (e) => {
const value = e.target.value;
setValue(value);
// value is coming from input change event directly
debouncedSendRequest(value);
};
但是我们在状态中也有这个值,难道我不能直接使用它吗?也许我有一个回调链,一遍又一遍地传递这个值真的很困难。也许我想访问另一个状态变量,通过这样的回调传递它没有任何意义。或者也许我只是讨厌回调和参数,只是想使用状态。应该够简单了吧?
当然,事情并没有看起来那么简单。如果我去掉参数,直接使用value
from 状态,就必须把它添加到useCallback
hook 的依赖项中:
const Input = () => {
const [value, setValue] = useState("initial");
const sendRequest = useCallback(() => {
// value is now coming from state
console.log("Changed value:", value);
// adding it to dependencies
}, [value]);
}
因此,sendRequest
函数会在每次值更改时发生变化——这就是记忆化的工作原理,在依赖项发生变化之前,值在整个重新渲染过程中都保持不变。这意味着我们记忆化的去抖动调用现在也会不断变化——它有sendRequest
一个依赖项,现在会随着每次状态更新而变化。
// this will now change on every state update
// because sendRequest has dependency on state
const debouncedSendRequest = useMemo(() => {
return debounce(sendRequest, 1000);
}, [sendRequest]);
我们回到了第一次将状态引入Input
组件时的状态:去抖动变成了延迟。
这里能做些什么吗?
如果您搜索有关去抖和 React 的文章,其中一半会提到useRef
避免在每次重新渲染时重新创建去抖函数的方法。useRef
是一个有用的钩子,它允许我们创建ref
一个在重新渲染之间持久的可变对象。ref
在这种情况下只是记忆的替代方案。
通常,模式如下:
const Input = () => {
// creating ref and initializing it with the debounced backend call
const ref = useRef(debounce(() => {
// this is our old "debouncedSendRequest" function
}, 500));
const onChange = (e) => {
const value = e.target.value;
// calling the debounced function
ref.current();
};
}
这实际上可能是之前基于useMemo
和 的解决方案的一个不错的替代方案useCallback
。我不知道你是怎么想的,但那些钩子链让我头疼,眼皮也抽搐。根本读不懂也理解不了!基于 ref 的解决方案看起来容易得多。
不幸的是,这个解决方案只适用于前面提到的用例:回调函数中没有状态的时候。想想看,debounce
这个函数只会被调用一次:组件挂载并ref
初始化时。这个函数创建了所谓的“闭包”:它创建时可用的外部数据将被保留下来供它使用。换句话说,如果我value
在这个函数中使用状态:
const ref = useRef(debounce(() => {
// this value is coming from state
console.log(value);
}, 500));
该值将在函数创建时“冻结”——即初始状态值。当这样实现时,如果我想访问最新的状态值,我需要debounce
再次调用该函数useEffect
并将其重新赋值给引用。我不能直接更新它。完整的代码如下所示:
const Input = () => {
const [value, setValue] = useState();
// creating ref and initializing it with the debounced backend call
const ref = useRef(debounce(() => {
// send request to the backend here
}, 500));
useEffect(() => {
// updating ref when state changes
ref.current = debounce(() => {
// send request to the backend here
}, 500);
}, [value]);
const onChange = (e) => {
const value = e.target.value;
// calling the debounced function
ref.current();
};
}
但不幸的是,这与依赖项解决方案没有什么不同useCallback
:每次都会重新创建 debounced 函数,每次都会重新创建里面的计时器,而 debounce 无非就是重新命名而已delay
。
亲自看看:
但我们实际上已经发现了一些问题,解决方案已经很接近了,我能感觉到。
这里我们可以利用的一点是,JavaScript 中的对象并非不可 变的。只有原始值(例如数字或对象的引用)才会在创建闭包时被“冻结”。如果sendRequest
我在“冻结”函数中尝试访问ref.current
(根据定义,它是可变的),那么我将始终获得它的最新版本!
让我们回顾一下:ref
是可变的;我只能debounce
在挂载时调用一次函数;当我调用它时,将创建一个闭包,其中包含来自外部的原始值,例如state
内部的“冻结”值;可变对象不会被“冻结”。
因此,实际的解决方案是:将非去抖动不断重新创建的sendRequest
函数附加到 ref;在每次状态改变时更新它;只创建一次“去抖动”函数;将一个访问的函数传递给它ref.current
- 它将是可以访问最新状态的最新 sendRequest。
用闭包思考让我的大脑崩溃了🤯,但它确实有效,而且更容易在代码中遵循这种思路:
const Input = () => {
const [value, setValue] = useState();
const sendRequest = () => {
// send request to the backend here
// value is coming from state
console.log(value);
};
// creating ref and initializing it with the sendRequest function
const ref = useRef(sendRequest);
useEffect(() => {
// updating ref when state changes
// now, ref.current will have the latest sendRequest with access to the latest state
ref.current = sendRequest;
}, [value]);
// creating debounced callback only once - on mount
const debouncedCallback = useMemo(() => {
// func will be created only once - on mount
const func = () => {
// ref is mutable! ref.current is a reference to the latest sendRequest
ref.current?.();
};
// debounce the func that was created once, but has access to the latest sendRequest
return debounce(func, 1000);
// no dependencies! never gets updated
}, []);
const onChange = (e) => {
const value = e.target.value;
// calling the debounced function
debouncedCallback();
};
}
现在,我们需要做的就是把那个令人麻木的闭包疯狂提取到一个小小的钩子中,把它放在一个单独的文件中,然后假装没有注意到它😅
const useDebounce = (callback) => {
const ref = useRef();
useEffect(() => {
ref.current = callback;
}, [callback]);
const debouncedCallback = useMemo(() => {
const func = () => {
ref.current?.();
};
return debounce(func, 1000);
}, []);
return debouncedCallback;
};
然后我们的生产代码就可以使用它,而不需要令人眼花缭乱的useMemo
和链useCallback
,不用担心依赖关系,并且可以访问里面的最新状态和道具!
const Input = () => {
const [value, setValue] = useState();
const debouncedRequest = useDebounce(() => {
// send request to the backend
// access to latest state here
console.log(value);
});
const onChange = (e) => {
const value = e.target.value;
setValue(value);
debouncedRequest();
};
return <input onChange={onChange} value={value} />;
}
是不是很漂亮?你可以在这里尝试一下最终的代码:
在你反弹之前
希望这些内容对你有用,现在你对 debounce 和 throttle 是什么、如何在 React 中使用它们以及每个解决方案的注意事项有了更多的了解。
别忘了:debounce
orthrottle
只是带有内部时间跟踪器的函数。它们只在组件挂载时调用一次。如果带有去抖动回调的组件需要不断重新渲染,请使用诸如记忆化或创建回调之类的技术。如果您想在去抖动函数中访问最新的状态或 props,而不是通过参数传递所有数据,ref
请利用 JavaScript 闭包和 React 。ref
愿力量永远不会从你身上反弹✌🏼
最初发表于https://www.developerway.com。该网站还有更多类似的文章 😉
订阅时事通讯、在 LinkedIn 上联系或在 Twitter 上关注,以便在下一篇文章发布时立即收到通知。
链接:https://dev.to/adevnadia/how-to-debounce-and-throttle-in-react-without-losing-your-mind-pg5