Enhancing Vue Code: Avoiding Misuse of Watch Functions
Written on
My article is accessible to all; non-members can follow this link to view the complete text.
Foreword
Last Friday evening at 8 PM, I was eagerly anticipating the completion of product acceptance to ensure a smooth launch.
Unexpectedly, the product team approached me, indicating that new features needed to be added, and the colleague responsible for that segment had already left for the day.
Although I felt a rush of frustration internally, I agreed to take on the task without voicing any objections.
This particular section of the business was intricate, and my unfamiliarity with it, combined with hunger, nearly caused me to falter while navigating the code logic.
The Vue file requiring changes contained thousands of lines of code and over ten watch instances tied to ref variables related to business logic iteration.
I spent considerable time deciphering the logic behind these watches before cautiously integrating new business logic into the existing code, fearful of unintentionally disrupting the original logic and facing blame for any bugs.
The Challenges of Overusing Watch
Let’s examine a specific example:
<template>
{{ dataList }}</template>
<script setup lang="ts">
import { ref, watch } from "vue";
const dataList = ref([]);
const props = defineProps(["disableList", "type", "id"]);
watch(
() => props.disableList,
() => {
// The logic based on disableList is very complex, and it computes a new list synchronously
const newList = getListFromDisabledList(dataList.value);
dataList.value = newList;
},
{ deep: true }
);
watch(
() => props.type,
() => {
// The logic based on type is very complex and computes a new list synchronously
const newList = getListFromType(dataList.value);
dataList.value = newList;
}
);
watch(
() => props.id,
() => {
// Fetch dataList from the server
fetchDataList();
},
{ immediate: true }
);
</script>
In this scenario, dataList is displayed in the template. When props.id is modified and at initialization, dataList is fetched asynchronously from the server.
Updates to props.disableList and props.type result in synchronous recalculations of dataList.
At first glance, the code appears acceptable, but complications emerge when a new colleague unfamiliar with this segment assumes responsibility.
Typically, when inheriting an unfamiliar business area, we require a point of reference.
In frontend development, this reference is usually the rendered page in the browser.
In Vue, since the page is generated from templates, identifying the reactive variables used within the template and their sources can clarify the business logic.
For instance, tracing the origins of the dataList variable usually sheds light on the business logic.
In this example, dataList is sourced from various places. Initially, it is updated asynchronously from the server via a watch on props.id. Subsequently, it is synchronously modified through watches on both props.disableList and props.type.
At this stage, a colleague unfamiliar with the business who receives a request to revise the logic for dataList must first understand the logic behind its multiple sources.
After grasping the logic, they must analyze which watch requires modification to align with the product specifications.
However, in practice, when managing someone else's code (especially complex ones), we tend to avoid altering existing code and instead add our own on top.
Modifying intricate legacy code risks introducing bugs for which we might be held responsible. Consequently, our common approach is to add another watch to implement the latest business logic for dataList:
watch(
() => props.xxx,
() => {
// Add the latest business logic
const newList = getListFromXxx(dataList.value);
dataList.value = newList;
}
);
After several iterations, this Vue file can become overcrowded with numerous watch statements, resulting in "spaghetti code."
There might be cases where this coding style is deliberate, as it secures one's position within the team, making others hesitant to modify such a convoluted section of code.
Using Computed to Address the Issue
Recognizing the shortcomings of the previous example, what should maintainable code resemble? In my perspective, it should look like this:
dataList is displayed in the template, then updated synchronously, followed by an asynchronous fetch from the server.
This process can be visualized as a single thread. When a new developer joins the team to iterate on dataList-related business, they only need to ascertain whether the latest product requirements necessitate modifying the code during the synchronous or asynchronous phase, and then add the new code accordingly.
Let’s explore how to optimize the previous example for enhanced maintainability. The source of dataList can primarily be categorized into synchronous and asynchronous origins.
The asynchronous source cannot be modified, as business dictates that after props.id is updated, the latest dataList must be retrieved from the server.
We can consolidate all synchronous source code into computed. The optimized code is as follows:
<template>
{{ renderDataList }}</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
const props = defineProps(["disableList", "type", "id"]);
const dataList = ref([]);
const renderDataList = computed(() => {
// Calculate the list based on disableList
const newDataList = getListFromDisabledList(dataList.value);
// Calculate the list based on type
return getListFromType(newDataList);
});
watch(
() => props.id,
() => {
// Fetch dataList from the server
fetchDataList();
},
{
immediate: true,}
);
</script>
In the template, we now render renderDataList instead of dataList. renderDataList is a computed property that incorporates all synchronous logic tied to dataList. The flowchart for the code logic is as follows:
When a new team member is tasked with iterating on dataList-related business, they can quickly clarify the business logic due to the linear sequence of the entire structure.
They can then decide, based on the product specifications, whether to adjust the synchronous or asynchronous logic. Below is a demonstration of modifying the synchronous logic:
const renderDataList = computed(() => {
// Add the latest business logic from the product
const xxxList = getListFromXxx(dataList.value);
// Calculate the list based on disableList
const newDataList = getListFromDisabledList(xxxList);
// Calculate the list based on type
return getListFromType(newDataList);
});
Conclusion
This article outlines two primary scenarios for using watch: one for when the watched value changes and requires synchronous updates to dataList, and the other for when it changes and necessitates asynchronous fetching of dataList from the server.
If both types of updates are carelessly mixed within the watch function, the subsequent maintainers will struggle to untangle the dataList-related logic.
This confusion arises because the watch function is altering the dataList values throughout, making it unclear where to implement new business logic.
In such cases, we typically add a new watch to encapsulate the latest business logic.
Over time, if the code accumulates numerous watch instances, maintainability declines.
Our optimization strategy is to consolidate all watch code responsible for synchronously updating dataList into a computed property named renderDataList.
Future maintainers need only determine if a new business requirement entails a synchronous update to dataList, in which case they should implement the new logic within computed.
If it involves an asynchronous update to dataList, the new business logic should be placed in watch.