Hone logo
Hone
Problems

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:

  1. Composable Function: The solution should be a composable function (a function starting with use).
  2. 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 delay milliseconds.
  3. Vue Integration: The composable should seamlessly integrate with Vue's reactivity system. It needs to accept a Vue watch source (ref, reactive object, getter function) and an options object.
  4. Callback Execution: The provided callback function should be the one that gets executed with throttling.
  5. Cleanup: Ensure proper cleanup of any timers or event listeners when the component unmounts.

Expected Behavior:

  • When the watched source changes, the callback should be invoked.
  • If the source changes again before the delay has passed since the last execution, the callback should not be invoked immediately.
  • The callback should eventually be invoked after the delay has passed since the last successful execution, provided there was a change during that interval.
  • The useThrottledWatch function should return an unwatch function, similar to watch, 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.
  • delay of 0: How should a delay of 0 be handled? It should effectively behave like a regular watch (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:

  1. The user types "appl". searchTerm updates rapidly.
  2. The useThrottledWatch callback is triggered by the changes to searchTerm.
  3. The performSearch function is called immediately after the 500ms throttle delay has passed since the last invocation of performSearch.
  4. If the user types "apple" within 500ms of typing "appl", the performSearch for "appl" might have already been scheduled or executed. The new search for "apple" will be scheduled to run 500ms after the last time performSearch was 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:

  1. As the user scrolls, the handleScroll function updates currentScrollTop.value.
  2. Each update to currentScrollTop triggers the useThrottledWatch logic.
  3. The updateScrollPosition function (the callback) will only be called at most once every 200ms, ensuring that the console.log and any other associated logic don't run too frequently.

Constraints

  • The delay option will be a non-negative integer representing milliseconds.
  • The watched source can be a Vue Ref, a Reactive object, or a getter function () => T.
  • The callback function should accept newValue and oldValue as arguments, similar to Vue's watch.
  • 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 setTimeout and clearTimeout for managing the throttling delay.
  • Think about how to track the last execution time.
  • Remember to handle the unwatch functionality.
  • The options object passed to your composable can also include immediate, but for this challenge, we are focusing on the delay aspect. You can ignore immediate or 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.
Loading editor...
typescript