首頁  >  文章  >  web前端  >  如何調整自動完成/選擇欄位以與伺服器端過濾和分頁一起使用

如何調整自動完成/選擇欄位以與伺服器端過濾和分頁一起使用

WBOY
WBOY原創
2024-09-04 16:36:241100瀏覽

How to adapt an autocomplete/select field to work with server-side filtering and pagination

介紹

在前端開發中,有大量的組件框架可供選擇,可以為大多數類型的問題提供簡單的解決方案。不過,您經常會遇到需要客製化的問題。有些框架比其他框架在更大程度上允許這樣做,但並非所有框架都同樣容易自訂。 Vuetify 是功能最豐富的框架之一,擁有非常詳細的文件。但在實踐中,研究一些看似微不足道的功能並提出優化的解決方案仍然需要大量時間。

辨識挑戰

Vuetify 的自動完成元件非常棒。在自訂方面,它為用戶提供了多種視覺和功能選項。雖然某些模式可以透過單一屬性觸發,但其他模式則需要付出更多努力,而且解決方法並不總是那麼簡單。在本文中,我將介紹利用無限滾動概念實現伺服器端過濾和分頁的解決方案。此外,這裡討論的技術也可以應用於 v-select 元件。

解決方案:伺服器端增強

在本章中,我們將概述使用伺服器端邏輯增強 v-autocomplete 的解決方案。首先,我們將其包裝到我們自己的自訂元件中,該元件將用於進行進一步的調整。使用內建的附加項插槽與 Vuetify 的 v-intersect 指令結合,我們將實現所謂的無限滾動。這意味著我們一開始只會載入少量記錄。透過上述組合,我們將偵測何時到達清單底部。此時,我們會自動發送後續請求來載入下一頁記錄,直到最終到達底部。

之後,我們將透過調整 v-autocomplete 的屬性、停用前端過濾、添加足夠的指示器和處理滾動位置來擴展我們的解決方案以包括過濾,以確保最終用戶獲得流暢直觀的體驗。我們最後會得到這樣的結果:

How to adapt an autocomplete/select field to work with server-side filtering and pagination

設定事情

技術實作將透過 Vue(我日常工作的首選框架)以及 Vuetify(Vue 生態系統中常用的非常強大且高度可自訂的元件框架)進行演示。請注意,此處使用的概念可以使用流行 JavaScript 技術的其他組合來應用。

根據 Vue 和 Vuetify 版本的不同,解決方案會略有不同。由於兩者的 3.x 版本已經發布相當長一段時間並且現在已成為行業標準,因此我將使用它們。不過,我將為 Vue 2/Vuetify 2 留下重要註釋,因為許多活躍專案仍在使用它們。差異通常很小,除了存取內部 Vuetify 元素時(這在 Vue 3 中更難做到,因為不支援 $refs)。

首先,我們將建立一個新的空白項目。如果您希望將解決方案新增至現有專案中,則可以跳過本段。使用節點套件管理器(NPM),我們將使用以下命令建立專案:npm create vue@latest。預設設定適合我們的目的,但如果您願意,可以更改它們。我啟用了 ESLint 和 Prettier 選項。還有其他方式來啟動 Vue 項目,但我更喜歡這種方式,因為它預設使用 Vite 作為開發伺服器。

接下來,我們需要新增 Vuetify 以及其中未包含的基本相依性。除非您選擇其他圖示字體或更喜歡其他 CSS 選項,否則可以執行以下命令:npm install vuetify @mdi/font sass。依照官方文檔,您可以在 main.js 文件中設定 Vuetify。如果您像我一樣使用 MDI 圖標,請不要忘記字體行。

// file: main.js
import './assets/main.css';

import { createApp } from 'vue';
import App from './App.vue';

import '@mdi/font/css/materialdesignicons.css';
import 'vuetify/styles';
import { createVuetify } from 'vuetify';
import { VAutocomplete } from 'vuetify/components';
import { Intersect } from 'vuetify/directives';

const vuetify = createVuetify({
  components: { VAutocomplete },
  directives: { Intersect }
});

createApp(App).use(vuetify).mount('#app');

對於我們的後端,我選擇使用帶有虛假資料的免費 API 服務,稱為 JSON Placeholder。雖然它不是您在生產應用程式中使用的東西,但它是一項簡單且免費的服務,只需最少的調整即可為我們提供所需的一切。

現在,讓我們深入了解實際的編碼過程。在元件目錄中建立一個新的 Vue 檔案。根據您的喜好命名 - 我選擇了 PaginatedAutocomplete.vue。新增包含單一 v-autocomplete 元素的範本部分。為了用資料填充此元素,我們將定義一個將傳遞給元件的記錄屬性。

For some minor styling adjustments, consider adding classes or props to limit the width of the autocomplete field and its dropdown menu to around 300px, preventing it from stretching across the entire window width.

// file: PaginatedAutocomplete.vue
<template>
  <v-autocomplete :items="items" :menu-props="{ maxWidth: 300 }" class="autocomplete">
    <!--  -->
  </v-autocomplete>
</template>

<script setup>
defineProps({
  items: {
    type: Array,
    required: true
  }
});
</script>

<style lang="scss" scoped>
.autocomplete {
  width: 300px;
}
</style>

In the App.vue file, we can delete or comment out the header and Welcome components and import our newly created PaginatedAutocomplete.vue. Add the data ref that will be used for it: records, and set its default value to an empty array.

// file: App.vue
<script setup>
import { ref } from 'vue';

import PaginatedAutocomplete from './components/PaginatedAutocomplete.vue';

const records = ref([]);
</script>

<template>
  <main>
    <PaginatedAutocomplete :items="records" />
  </main>
</template>

Adjust global styles if you prefer. I changed the color scheme from dark to light in base.css and added some centering CSS to main.css.

That completes the initial setup. So far, we only have a basic autocomplete component with empty data.

Controlling Data Flow with Infinite Scroll

Moving forward, we need to load the data from the server. As previously mentioned, we will be utilizing JSON Placeholder, specifically its /posts endpoint. To facilitate data retrieval, we will install Axios with npm install axios.

In the App.vue file, we can now create a new method to fetch those records. It’s a simple GET request, which we follow up by saving the response data into our records data property. We can call the function inside the onMounted hook, to load the data immediately. Our script section will now contain this:

// file: App.vue
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';

import PaginatedAutocomplete from './components/PaginatedAutocomplete.vue';

const records = ref([]);

function loadRecords() {
  axios
    .get('https://jsonplaceholder.typicode.com/posts')
    .then((response) => {
      records.value = response.data;
    })
    .catch((error) => {
      console.log(error);
    });
}

onMounted(() => {
  loadRecords();
});
</script>

To improve the visual user experience, we can add another data prop called loading. We set it to true before sending the request, and then revert it to false after the response is received. The prop can be forwarded to our PaginatedAutocomplete.vue component, where it can be tied to the built-in v-autocomplete loading prop. Additionally, we can incorporate the clearable prop. That produces the following code:

// file: Paginated Autocomplete.vue
<template>
  <v-autocomplete
    :items="items"
    :loading="loading"
    :menu-props="{ maxWidth: 300 }"
    class="autocomplete"
    clearable
  >
    <!--  -->
  </v-autocomplete>
</template>

<script setup>
defineProps({
  items: {
    type: Array,
    required: true
  },

  loading: {
    type: Boolean,
    required: false
  }
});
</script>
// file: App.vue
// ...
const loading = ref(false);

function loadRecords() {
  loading.value = true;

  axios
    .get('https://jsonplaceholder.typicode.com/posts')
    .then((response) => {
      records.value = response.data;
    })
    .catch((error) => {
      console.log(error);
    })
    .finally(() => {
      loading.value = false;
    });
}
// ...
<!-- file: App.vue -->
<!-- ... -->
<PaginatedAutocomplete :items="records" :loading="loading" />
<!-- ... -->

At this point, we have a basic list of a hundred records, but it’s not paginated and it doesn’t support searching. If you’re using Vuetify 2, the records won’t show up correctly - you will need to set the item-text prop to title. This is already the default value in Vuetify 3. Next, we will adjust the request parameters to attain the desired behavior. In a real project, the back-end would typically provide you with parameters such as page and search/query. Here, we have to get a little creative. We can define a pagination object on our end with page: 1, itemsPerPage: 10 and total: 100 as the default values. In a realistic scenario, you likely wouldn’t need to supply the first two for the initial request, and the third would only be received from the response. JSON Placeholder employs different parameters called _start and _limit. We can reshape our local data to fit this.

// file: App.vue
// ...
const pagination = ref({
  page: 1,
  perPage: 10,
  total: 100
});

function loadRecords() {
  loading.value = true;

  const params = {
    _start: (pagination.value.page - 1) * pagination.value.perPage,
    _limit: pagination.value.perPage
  };

  axios
    .get('https://jsonplaceholder.typicode.com/posts', { params })
    .then((response) => {
      records.value = response.data;
      pagination.value.total = response.headers['x-total-count'];
    })
    .catch((error) => {
      console.log(error);
    })
    .finally(() => {
      loading.value = false;
    });
}
// ...

Up to this point, you might not have encountered any new concepts. Now we get to the fun part - detecting the end of the current list and triggering the request for the next page of records. Vuetify has a directive called v-intersect, which can inform you when a component you attached it to enters or leaves the visible area in your browser. Our interest lies in its isIntersecting return argument. The detailed description of what it does can be found in MDN Web Docs. In our case, it will allow us to detect when we’ve reached the bottom of the dropdown list. To implement this, we will attach the directive to our v-autocomplete‘s append-item slot.

To ensure we don’t send multiple requests simultaneously, we display the element only when there’s an intersection, more records are available, and no requests are ongoing. Additionally, we add the indicator to show that a request is currently in progress. This isn’t required, but it improves the user experience. Vuetify’s autocomplete already has a loading bar, but it might not be easily noticeable if your eyes are focused on the bottom of the list. We also need to update the response handler to concatenate records instead of replacing them, in case a page other than the first one was requested.

To handle the intersection, we check for the first (in Vuetify 2, the third) parameter (isIntersecting) and emit an event to the parent component. In the latter, we follow this up by sending a new request. We already have a method for loading records, but before calling it, we need to update the pagination object first. We can do this in a new method that encapsulates the old one. Once the last page is reached, we shouldn’t send any more requests, so a condition check for that should be added as well. With that implemented, we now have a functioning infinite scroll.

// file: PaginatedAutocomplete.vue
<template>
  <v-autocomplete
    :items="items"
    :loading="loading"
    :menu-props="{ maxWidth: 300 }"
    class="autocomplete"
    clearable
  >
    <template #append-item>
      <template v-if="!!items.length">
        <div v-if="!loading" v-intersect="handleIntersection" />

        <div v-else class="px-4 py-3 text-primary">Loading more...</div>
      </template>
    </template>
  </v-autocomplete>
</template>

<script setup>
defineProps({
  items: {
    type: Array,
    required: true
  },

  loading: {
    type: Boolean,
    required: false
  }
});

const emit = defineEmits(['intersect']);

function handleIntersection(isIntersecting) {
  if (isIntersecting) {
    emit('intersect');
  }
}
</script>
// file: App.vue
// ...
function loadRecords() {
  loading.value = true;

  const params = {
    _start: (pagination.value.page - 1) * pagination.value.perPage,
    _limit: pagination.value.perPage
  };

  axios
    .get('https://jsonplaceholder.typicode.com/posts', { params })
    .then((response) => {
      if (pagination.value.page === 1) {
        records.value = response.data;
        pagination.value.total = response.headers['x-total-count'];
      } else {
        records.value = [...records.value, ...response.data];
      }
    })
    .catch((error) => {
      console.log(error);
    })
    .finally(() => {
      loading.value = false;
    });
}

function loadNextPage() {
  if (pagination.value.page * pagination.value.perPage >= pagination.value.total) {
    return;
  }

  pagination.value.page++;

  loadRecords();
}
// ...

Efficiency Meets Precision: Moving Search to the Back-end

To implement server-side searching, we begin by disabling from-end filtering within the v-autocomplete by adjusting the appropriate prop value (no-filter). Then, we introduce a new property to manage the search string, and then bind it to v-model:search-input (search-input.sync in Vuetify 2). This differentiates it from the regular input. In the parent component, we capture the event, define a query property, update it when appropriate, and reset the pagination to its default value, since we will be requesting page one again. We also have to update our request parameters by adding q (as recognized by JSON Placeholder).

// file: PaginatedAutocomplete.vue
<template>
  <v-autocomplete
    :items="items"
    :loading="loading"
    :menu-props="{ maxWidth: 300 }"
    class="autocomplete"
    clearable
    no-filter
    v-model:search-input="search"
    @update:search="emitSearch"
  >
    <template #append-item>
      <template v-if="!!items.length">
        <div v-if="!loading" v-intersect="handleIntersection" />

        <div v-else class="px-4 py-3 text-primary">Loading more...</div>
      </template>
    </template>
  </v-autocomplete>
</template>

<script setup>
import { ref } from 'vue';

defineProps({
  items: {
    type: Array,
    required: true
  },

  loading: {
    type: Boolean,
    required: false
  }
});

const emit = defineEmits(['intersect', 'update:search-input']);

function handleIntersection(isIntersecting) {
  if (isIntersecting) {
    emit('intersect');
  }
}

const search = ref(null);

function emitSearch(value) {
  emit('update:search-input', value);
}
</script>
// file: App.vue
<script>
// ...
const query = ref(null);

function handleSearchInput(value) {
  query.value = value;

  pagination.value = Object.assign({}, { page: 1, perPage: 10, total: 100 });

  loadRecords();
}

onMounted(() => {
  loadRecords();
});
</script>

<template>
  <main>
    <PaginatedAutocomplete
      :items="records"
      :loading="loading"
      @intersect="loadNextPage"
      @update:search-input="handleSearchInput"
    />
  </main>
</template>
// file: App.vue
// ...
function loadRecords() {
  loading.value = true;

  const params = {
    _start: (pagination.value.page - 1) * pagination.value.perPage,
    _limit: pagination.value.perPage,
    q: query.value
  };
// ...

If you try the search now and pay attention to the network tab in developer tools, you will notice that a new request is fired off with each keystroke. While our current dataset is small and loads quickly, this behavior is not suitable for real-world applications. Larger datasets can lead to slow loading times, and with multiple users performing searches simultaneously, the server could become overloaded. Fortunately, we have a solution in the Lodash library, which contains various useful JavaScript utilities. One of them is debouncing, which allows us to delay function calls by leaving us some time to call the same function again. That way, only the latest call within a specified time period will be triggered. A commonly used delay for this kind of functionality is 500 milliseconds. We can install Lodash by running the command npm install lodash. In the import, we only reference the part that we need instead of taking the whole library.

// file: PaginatedAutocomplete.vue
// ...
import debounce from 'lodash/debounce';
// ...
// file: PaginatedAutocomplete.vue
// ...
const debouncedEmit = debounce((value) => {
  emit('update:search-input', value);
}, 500);

function emitSearch(value) {
  debouncedEmit(value);
}
// ...

Now that’s much better! However, if you experiment with various searches and examine the results, you will find another issue - when the server performs the search, it takes into account not only post titles, but also their bodies and IDs. We don’t have options to change this through parameters, and we don’t have access to the back-end code to adjust that there either. Therefore, once again, we need to do some tweaking of our own code by filtering the response data. Note that in a real project, you would discuss this with your back-end colleagues. Loading unused data isn’t something you would ever want!

// file: App.vue
// ...
.then((response) => {
      const recordsToAdd = response.data.filter((post) => post.title.includes(params.q || ''));

      if (pagination.value.page === 1) {
        records.value = recordsToAdd;
        pagination.value.total = response.headers['x-total-count'];
      } else {
        records.value = [...records.value, ...recordsToAdd];
      }
    })
// ...

To wrap up all the fundamental functionalities, we need to add record selection. This should already be familiar to you if you’ve worked with Vuetify before. The property selectedRecord is bound to model-value (or just value in Vuetify 2). We also need to emit an event on selection change, @update:model-value, (Vuetify 2: @input) to propagate the value to the parent component. This configuration allows us to utilize v-model for our custom component.

Because of how Vuetify’s autocomplete component works, both record selection and input events are triggered when a record is selected. Usually, this allows more customization options, but in our case it’s detrimental, as it sends an unnecessary request and replaces our list with a single record. We can solve this by checking for selected record and search query equality.

// file: App.vue
// ...
function handleSearchInput(value) {
  if (selectedRecord.value === value) {
    return;
  }

  query.value = value;

  pagination.value = Object.assign({}, { page: 1, perPage: 10, total: 100 });

  loadRecords();
}

const selectedRecord = ref(null);
// ...
<!-- file: App.vue -->
<template>
  <main>
    <PaginatedAutocomplete
      v-model="selectedRecord"
      :items="records"
      :loading="loading"
      @intersect="loadNextPage"
      @update:search-input="handleSearchInput"
    />
  </main>
</template>
<!-- file: PaginatedAutocomplete.vue -->
<!-- ... -->
  <v-autocomplete
    :items="items"
    :loading="loading"
    :menu-props="{ maxWidth: 300 }"
    :model-value="selectedItem"
    class="autocomplete"
    clearable
    no-filter
    v-model:search-input="search"
    @update:model-value="emitSelection"
    @update:search="emitSearch"
  >
<!-- ... -->
// file: PaginatedAutocomplete.vue
// ...
const emit = defineEmits(['intersect', 'update:model-value', 'update:search-input']);

function handleIntersection(isIntersecting) {
  if (isIntersecting) {
    emit('intersect');
  }
}

const selectedItem = ref(null);

function emitSelection(value) {
  selectedItem.value = value;

  emit('update:model-value', value);
}
// ...

Almost done, but if you are thorough with your testing, you will notice an annoying glitch - when you do a search, scroll down, then do another search, the dropdown scroll will remain in the same place, possibly causing a chain of new requests in quick succession. To solve this, we can reset the scroll position to the top whenever a new search input is entered. In Vuetify 2, we could do this by referencing the internal v-menu of v-autocomplete, but since that’s no longer the case in Vuetify 3, we need to get creative. Applying a unique class name to the menu allows us to select it through pure JavaScript and then follow up with necessary adjustments.

<!-- file: PaginatedAutocomplete.vue -->
<!-- ... -->
<v-autocomplete
  ...
  :menu-props="{ maxWidth: 300, class: `dropdown-${uid}` }"
  ...
>
<!-- ... -->
// file: PaginatedAutocomplete.vue
// ...
const debouncedEmit = debounce((value) => {
  emit('update:search-input', value);

  resetDropdownScroll();
}, 500);

function emitSearch(value) {
  debouncedEmit(value);
}

const uid = Math.round(Math.random() * 10e4);

function resetDropdownScroll() {
  const menuWrapper = document.getElementsByClassName(`dropdown-${uid}`)[0];
  const menuList = menuWrapper?.firstElementChild?.firstElementChild;

  if (menuList) {
    menuList.scrollTop = 0;
  }
}
// ...

There we have it, our custom autocomplete component with server side filtering and pagination is now complete! It was rather simple in the end, but I’m sure you would agree that the way to the solution was anything but with all these little tweaks and combinations we had to make.

If you need to compare anything with your work, you can access the source files through a GitHub repository here.

概述與結論

旅程不必在這裡結束。如果您需要進一步定制,可以搜尋 Vuetify 文件以獲取想法。有無數的可能性等待探索。例如,您可以嘗試一次使用多個值。 Vuetify 已經支援這一點,但可能需要進行額外的調整才能與我們的解決方案結合。儘管如此,這在許多項目中仍然是有用的。或者,您可以嘗試模板自訂。您可以重新定義選擇範本、清單範本等的外觀和風格。這為製作與您的專案設計和品牌完美契合的使用者介面打開了大門。

除此之外還有很多其他選擇。事實上,可用的自訂深度保證了創建額外的文章來全面涵蓋這些高級主題。最後,Vue + Vuetify 堆疊並不是唯一支援此類功能的堆疊。如果您使用其他框架,我鼓勵您嘗試自行開發與此等效的框架。

總之,我們將一個基本元件轉變為適合我們需求的專業解決方案。您現在已經為自己配備了一個可應用於各種專案的多功能工具。每當您發現自己正在處理大量記錄清單時,伺服器端分頁和過濾解決方案就會成為您的首選策略。它不僅從伺服器的角度優化了效能,還確保為使用者提供更流暢的渲染體驗。透過一些調整,我們解決了一些常見問題,並為進一步調整開闢了新的可能性。

以上是如何調整自動完成/選擇欄位以與伺服器端過濾和分頁一起使用的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn