Implement a Throttled Watcher in Vue.js
Vue's built-in watch functionality is powerful, but sometimes you need to limit how often a watcher callback is executed. This is especially useful for performance-intensive operations like handling frequent DOM events (scroll, resize, input) or making API calls. This challenge asks you to implement a custom composable function that provides a throttled version of Vue's watch.
Problem Description
Your task is to create a TypeScript composable function, let's call it useThrottledWatch, that mimics the behavior of Vue's watch but with throttling. This means the watcher callback will only be executed at most once within a specified time interval.
Key Requirements:
- Composable Function: The solution should be a composable function (a function starting with
use). - Throttling Logic: Implement the throttling mechanism. When the watched source changes, the callback should be executed:
- Immediately if the specified delay has passed since the last execution.
- At most once per
delaymilliseconds.
- Vue Integration: The composable should seamlessly integrate with Vue's reactivity system. It needs to accept a Vue
watchsource (ref, reactive object, getter function) and an options object. - Callback Execution: The provided callback function should be the one that gets executed with throttling.
- Cleanup: Ensure proper cleanup of any timers or event listeners when the component unmounts.
Expected Behavior:
- When the watched source changes, the
callbackshould be invoked. - If the source changes again before the
delayhas passed since the last execution, the callback should not be invoked immediately. - The callback should eventually be invoked after the
delayhas passed since the last successful execution, provided there was a change during that interval. - The
useThrottledWatchfunction should return anunwatchfunction, similar towatch, to manually stop the watcher.
Edge Cases:
- Initial Call: Should the callback be executed on the initial load? For simplicity in this challenge, assume it is not executed on the initial load unless the source changes immediately.
- Rapid Changes: What happens if the source changes many times within the
delay? Only the latest change should trigger the throttled callback after the delay. delayof 0: How should adelayof 0 be handled? It should effectively behave like a regularwatch(or a throttled version with a very small, practical delay to avoid extreme race conditions).
Examples
Let's illustrate with conceptual examples using a simplified Vue component structure.
Example 1: Throttling a Search Input
Imagine a search input where we only want to trigger an API search after the user stops typing for a short period.
<template>
<input v-model="searchTerm" placeholder="Search..." />
<p>Last searched for: {{ lastSearchTerm }}</p>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useThrottledWatch } from './useThrottledWatch'; // Assume this is your composable
const searchTerm = ref('');
const lastSearchTerm = ref('');
const performSearch = (query: string) => {
console.log(`Performing search for: ${query}`);
lastSearchTerm.value = query;
// In a real app, this would be an API call
};
// Throttle the search to run at most once every 500ms
useThrottledWatch(
searchTerm,
(newValue, oldValue) => {
performSearch(newValue);
},
{ delay: 500 }
);
</script>
Explanation:
- The user types "appl".
searchTermupdates rapidly. - The
useThrottledWatchcallback is triggered by the changes tosearchTerm. - The
performSearchfunction is called immediately after the 500ms throttle delay has passed since the last invocation ofperformSearch. - If the user types "apple" within 500ms of typing "appl", the
performSearchfor "appl" might have already been scheduled or executed. The new search for "apple" will be scheduled to run 500ms after the last timeperformSearchwas allowed to execute.
Example 2: Throttling Scroll Event Handling
Consider a component that needs to update its state based on scroll position, but only periodically to avoid performance issues.
<template>
<div class="scrollable" @scroll="handleScroll">
<!-- Lots of content here -->
</div>
<p>Scroll position: {{ scrollPosition }}</p>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useThrottledWatch } from './useThrottledWatch';
const scrollableDiv = ref<HTMLDivElement | null>(null);
const scrollPosition = ref(0);
const updateScrollPosition = () => {
if (scrollableDiv.value) {
scrollPosition.value = scrollableDiv.value.scrollTop;
console.log(`Updated scroll position: ${scrollPosition.value}`);
}
};
// We'll use a ref to hold the scroll event target for the watcher
const scrollTarget = ref<EventTarget | null>(null);
// Simulate setting the scroll target when the component is mounted
onMounted(() => {
if (scrollableDiv.value) {
scrollTarget.value = scrollableDiv.value;
}
});
// Watch the scroll target and throttle the update function
// Note: This is a conceptual example. In practice, you'd likely attach
// an event listener directly, but this demonstrates watching a ref.
// A more direct approach might involve watching a ref that holds the
// scroll position itself. Let's refine this to watch a ref that holds
// the scroll position value.
const currentScrollTop = ref(0);
const handleScroll = (event: Event) => {
const target = event.target as HTMLDivElement;
currentScrollTop.value = target.scrollTop;
};
// Watch the currentScrollTop ref and throttle the update function
useThrottledWatch(
currentScrollTop,
(newScrollTop) => {
updateScrollPosition(); // This callback will be throttled
},
{ delay: 200 } // Update at most every 200ms
);
</script>
Explanation:
- As the user scrolls, the
handleScrollfunction updatescurrentScrollTop.value. - Each update to
currentScrollToptriggers theuseThrottledWatchlogic. - The
updateScrollPositionfunction (the callback) will only be called at most once every 200ms, ensuring that theconsole.logand any other associated logic don't run too frequently.
Constraints
- The
delayoption will be a non-negative integer representing milliseconds. - The watched source can be a Vue
Ref, aReactiveobject, or a getter function() => T. - The callback function should accept
newValueandoldValueas arguments, similar to Vue'swatch. - The implementation should be written in TypeScript.
- The solution should not rely on external throttling libraries (e.g., Lodash). Implement the throttling logic yourself.
Notes
- Consider using
setTimeoutandclearTimeoutfor managing the throttling delay. - Think about how to track the last execution time.
- Remember to handle the
unwatchfunctionality. - The
optionsobject passed to your composable can also includeimmediate, but for this challenge, we are focusing on thedelayaspect. You can ignoreimmediateor implement it as a bonus. The primary goal is throttling. - This challenge is a good exercise in understanding Vue's reactivity system and implementing custom logic that integrates with it.