首页 > web前端 > js教程 > 正文

如何调整自动完成/选择字段以与服务器端过滤和分页一起使用

WBOY
发布: 2024-09-04 16:36:24
原创
1132 人浏览过

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中文网其他相关文章!

来源:dev.to
本站声明
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn
热门教程
更多>
最新下载
更多>
网站特效
网站源码
网站素材
前端模板
关于我们 免责声明 Sitemap
PHP中文网:公益在线PHP培训,帮助PHP学习者快速成长!