オートコンプリート/選択フィールドをサーバー側のフィルタリングとページネーションで動作するように調整する方法

WBOY
リリース: 2024-09-04 16:36:24
オリジナル
1131 人が閲覧しました

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

導入

フロントエンド開発には、ほとんどの種類の問題に対する簡単な解決策を提供するコンポーネント フレームワークが豊富に揃っています。ただし、カスタマイズが必要な問題が頻繁に発生します。一部のフレームワークではこれが他のフレームワークよりも大幅に可能ですが、すべてのフレームワークが同じように簡単にカスタマイズできるわけではありません。 Vuetify は、非常に詳細なドキュメントを備えた最も機能が豊富なフレームワークの 1 つです。ただし、実際には、一見些細に見えるいくつかの機能を調査し、最適化されたソリューションを考案するには、依然としてかなりの時間がかかる可能性があります。

課題の特定

Vuetify のオートコンプリート コンポーネントは素晴らしいです。カスタマイズに関しては、視覚的にも機能的にもさまざまなオプションがユーザーに提供されます。一部のモードは 1 つのプロパティでトリガーできますが、他のモードはより多くの労力を必要とし、解決への道は必ずしも簡単ではありません。この記事では、無限スクロールの概念を活用して、サーバー側のフィルタリングとページネーションを実装するためのソリューションについて説明します。さらに、ここで説明するテクニックは 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 と、Vue エコシステムで一般的に使用される非常に堅牢で高度にカスタマイズ可能なコンポーネント フレームワークである Vuetify を組み合わせてデモンストレーションします。ここで使用されている概念は、一般的な JavaScript テクノロジの他の組み合わせを使用して適用できることに注意してください。

解決策は、Vue と Vuetify のバージョンによって若干異なります。どちらのバージョン 3.x もリリースされてからかなりの時間が経っており、現在は業界標準となっているため、私はそれらを使用するつもりです。ただし、多くのアクティブなプロジェクトがまだ Vue 2/Vuetify 2 を使用しているため、Vue 2/Vuetify 2 についての重要なメモを残しておきます。通常、内部の Vuetify 要素へのアクセスを除いて、違いはわずかです ($refs がサポートされていないため、Vue 3 ではアクセスが困難です)。

まず、新しい空のプロジェクトを作成します。既存のプロジェクトにソリューションを追加したい場合は、この段落をスキップできます。ノード パッケージ マネージャー (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');
ログイン後にコピー

バックエンドには、JSON Placeholder と呼ばれる偽のデータを含む無料の API サービスを使用することにしました。これは実稼働アプリで使用するものではありませんが、最小限の調整で必要なものすべてを提供できるシンプルで無料のサービスです。

それでは、実際のコーディングプロセスを見ていきましょう。コンポーネント ディレクトリ内に新しい Vue ファイルを作成します。好きな名前を付けます。私は PaginatedAutocomplete.vue を選択しました。単一の v-autocomplete 要素を含むテンプレート セクションを追加します。この要素にデータを設定するには、コンポーネントに渡される records プロパティを定義します。

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.

Gambaran Keseluruhan dan Kesimpulan

Perjalanan tidak semestinya berakhir di sini. Jika anda memerlukan penyesuaian lanjut, anda boleh mencari di dokumentasi Vuetify untuk mendapatkan idea. Terdapat banyak kemungkinan yang menunggu untuk diterokai. Sebagai contoh, anda boleh cuba bekerja dengan berbilang nilai sekaligus. Ini sudah disokong oleh Vuetify, tetapi mungkin memerlukan pelarasan tambahan untuk digabungkan dengan penyelesaian kami. Namun, ini adalah perkara yang berguna untuk ada dalam banyak projek. Atau, anda boleh mencuba penyesuaian templat. Anda mempunyai kuasa untuk mentakrifkan semula rupa dan rasa templat pilihan anda, templat senarai dan banyak lagi. Ini membuka pintu untuk mencipta antara muka pengguna yang selaras dengan reka bentuk dan penjenamaan projek anda dengan sempurna.

Terdapat banyak pilihan lain selain daripada itu. Malah, kedalaman penyesuaian yang tersedia menjamin penciptaan artikel tambahan untuk merangkumi topik lanjutan ini secara menyeluruh. Akhirnya, timbunan Vue + Vuetify bukanlah satu-satunya yang menyokong sesuatu seperti ini. Jika anda bekerja dengan rangka kerja lain, saya menggalakkan anda untuk cuba membangunkan rangka kerja yang setara dengan ini sendiri.

Kesimpulannya, kami mengubah komponen asas kepada penyelesaian khusus yang sesuai dengan keperluan kami. Anda kini telah mempersenjatai diri anda dengan alat serba boleh yang boleh digunakan untuk pelbagai projek. Setiap kali anda mendapati diri anda bekerja dengan senarai rekod yang luas, penomboran sebelah pelayan dan penyelesaian penapisan menjadi strategi pilihan anda. Ia bukan sahaja mengoptimumkan prestasi daripada perspektif pelayan, tetapi juga memastikan pengalaman pemaparan yang lebih lancar untuk pengguna anda. Dengan beberapa tweak, kami menangani beberapa isu biasa dan membuka kemungkinan baharu untuk pelarasan selanjutnya.

以上がオートコンプリート/選択フィールドをサーバー側のフィルタリングとページネーションで動作するように調整する方法の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

ソース:dev.to
このウェブサイトの声明
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。
人気のチュートリアル
詳細>
最新のダウンロード
詳細>
ウェブエフェクト
公式サイト
サイト素材
フロントエンドテンプレート
私たちについて 免責事項 Sitemap
PHP中国語ウェブサイト:福祉オンライン PHP トレーニング,PHP 学習者の迅速な成長を支援します!