搜索列表页,我终于找到使用舒适的方案了

2,509 阅读4分钟

搜索列表工具组件

本文内容 Vue 技术栈为基础。

背景

每一个搜索列表页面的业务功能都基本上一样。

需要实现的功能:

  1. 利用搜索表单数据进行搜索,点击搜素按钮时根据搜索表单数据,将页码重置为1进行数据查询;
  2. 点击重置按钮将表单恢复为默认值,将页码重置为1进行数据查询;
  3. 翻页器提供不同页码的翻页跳转。

但是在开发过程中,经常发现历史页面上的一些交互上的不良体验:

  1. 从列表页面进入详情页面,返回搜索列表页面后,搜索条件和页码丢失;
  2. 通过弹框表单的形式修改搜素列表的某一项元素后,刷新页面,搜索条件和页码丢失;
  3. 在搜索表单中修改了表单内容,但是在没有进行搜索的情况下进行了翻页,翻页的时候使用了搜索表单内的新数据,导致翻页后数据为空。

不知道各位同学的是否经历过这几种不良体验。本文以下部分会先正对这几个不良体验进行提出解决方案;再设计一个公共组件来实现搜索列表页的共同功能以及解决不良体验的方法。

解决不良体验

从列表页面进入详情页面,返回搜索列表页面后,搜索条件和页码丢失;

这个问题相信大部分同学和以前的我一样使用 Vue 原生组件 keepAlive,来缓存搜索列表页的实例。从列表页面进入详情页面,返回时重新渲染 keepAlive 缓存的搜索列表实例。
但是,事实上,并不是所有的场景都使用缓存的搜素列表页面。比如:从搜索列表页面进入一个编辑页面并修改了所属列表页上会显示的字段,当保持返回后,需要搜索列表页面依据刚才搜索条件和页码重新搜索。

为了解决上面这个问题,网上很多方案是使用keepAliveincludeexclude的动态值结合路由的方式,我也尝试过,但是这种方式让我使用的不舒服,不知道有没有和我有相同感受的同学。 还有一种有点点黑客的方案,就是不再需要使用缓存的时候,在keepAlive缓存实例的对象中找到对于的实例进行删除并 destory 掉。

这两个使用 keepAlive 都存在一个共同问题。在这样的一个场景:从搜索列表页面进入编辑页面,进行了编辑保存,返回搜索列表页,良好的用户体验是保持搜索条件和页码等状态,但是更新列表中被修改的数据。无论是通过 keepAliveincludeexclude的动态值去删除缓存实例还是直接去keepAlive的缓存对象中删除,搜索条件和页码等状态都会丢失。

为此,我妥协使用古老传统的方案,将搜索条件和页码固化在浏览器访问路径上。就是作为浏览器链接的 search 的一部分。这样当页面返回后,可以根据链接上的数据进行新的搜索。

通过弹框表单的形式修改搜素列表的某一项元素后,刷新页面,搜索条件和页码丢失;

以前的做法是,修改成功后,再把列表的数据进行相应的修改。 但是如果搜索条件和页码都已经被固化在浏览上了,那进行一次数据重新查询也是一个好的解决办法。

在搜索表单中修改了表单内容,但是在没有进行搜索的情况下进行了翻页,翻页的时候使用了搜索表单内的新数据,导致翻页后数据为空。

这个解决方案也是很简单,将表单元素双向绑定的数据和进行搜索的条件分开来,也就是在用户点击搜索的时候,将读取表单数据缓存在一个变量中。在搜索列表页面进行翻页跳转的时候使用缓存的数据,并且将缓存数据重新同步到表单,保证搜索的条件和表单显示的数据一致。

公用组件的实现

一个搜索列表页面需要完成这么多的工作细节,如果每一个搜索列表页面都要这样去做,那是要疯了。必须提取公共解决方案,保证每一个搜索列表页行为一致。

早些日子,我使用了mixin的方案,但是它是一个恶魔,严重影响了代码可阅读性。

最后,我选择使用公共组件的方式,把所有搜索列表页的公共业务逻辑都放在这个组件内。其中思路来自于,react 的业务容器组件,和 element-ui 的 tabel 组件。

实现代码如下:

<template>
  <div class="list-page">
    <slot
      :formData="searchFormData"
      :listData="listData"
      :search="search"
      :refresh="refresh"
      :resetForm="resetForm"
      :loading="loading"
    >
      <div>查询表单</div>
      <div>表格</div>
    </slot>
    <el-pagination
      v-if="totalSize > 0"
      :page-size="10"
      :total="totalSize"
      :current-page.sync="currentPage"
      @current-change="pageJump"
    />
  </div>
</template>
<script>
const cloneDeep = require('clone-deep')
function removeUrlQuery(url, key) {
  const urlObj = new URL(url)
  const reg = new RegExp(`[?&]${key}=[^?&$]*`, 'g')
  let search = urlObj.search.replace(reg, '')
  if (search && !search.startsWith('?')) {
    search = '?' + search
  }
  urlObj.search = search
  return urlObj.href
}
function cureDataToUrl(key, data) {
  let url = window.location.href
  url = removeUrlQuery(url, key)
  if (!data) {
    return
  }
  const urlObj = new URL(url)
  const query = `${key}=${encodeURIComponent(JSON.stringify(data))}`
  urlObj.search += (urlObj.search.startsWith('?') ? '&' : '?') + query
  url = urlObj.href
  history.pushState({ url: url }, '', url)
}
export default {
  name: 'SearchListTool',
  props: {
    ifGetDataImmediate: {
      type: Boolean,
      default: true
    },
    requestListMethod: {
      type: Function,
      required: true
    },
    defaultFormData: {
      type: Object,
      default() {
        return {}
      }
    }
  },
  data() {
    const {
      formData = this._getDefaultFormData(),
      currentPage = 1
    } = this._getSearchDataCacheFromUrl() || {}
    return {
      searchFormData: formData,
      currentPage: currentPage,
      listData: null,
      totalSize: 0,
      loading: false
    }
  },
  created() {
    if (this.ifGetDataImmediate) {
      this.getData()
    }
  },
  methods: {
    _cacheSearchData() {
      this._searchDataCache = {
        formData: cloneDeep(this.searchFormData),
        currentPage: this.currentPage
      }
    },
    _cureSearchDataToUrl() {
      cureDataToUrl('s', this._searchDataCache)
    },
    _syncSearchFormData() {
      this.searchFormData = this._searchDataCache.formData
    },
    _getSearchDataCacheFromUrl() {
      let { s: searchData } = this.$route.query
      if (searchData) {
        try {
          searchData = JSON.parse(searchData)
        } catch (error) {
          console.log(error)
        }
      }
      return searchData
    },
    search() {
      this.currentPage = 1
      this.getData()
    },
    pageJump() {
      this._syncSearchFormData()
      this.getData()
    },
    refresh() {
      this.pageJump()
    },
    resetForm() {
      this.searchFormData = this._getDefaultFormData()
      this.currentPage = 1
      this.totalSize = 0
      this.loading = false
      this.getData()
    },
    async getData() {
      this._cacheSearchData()
      this._cureSearchDataToUrl()
      const { formData = {}, currentPage = 1 } = this._searchDataCache || {}
      this.loading = true
      const request = this.requestListMethod(formData, currentPage)
      if (Object.prototype.toString.call(request) !== '[object Promise]') {
        throw new Error('request-list-method must return Promise instance, please')
      }
      try {
        const { listData, totalSize } = await request

        if (listData.length === 0 && currentPage > 1) {
          this.currentPage = currentPage - 1
          this.getData()
          return
        }

        this.listData = listData
        this.totalSize = totalSize
      } catch (error) {
        this.listData = []
        console.log(error)
      }
      this.loading = false
    },
    _getDefaultFormData() {
      return cloneDeep(this.defaultFormData)
    }
  }
}
</script>


使用方式

<template>
  <search-list-tool 
    :requestListMethod="requestListMethod"
    ref="searchListTool"
    v-slot="{formData, search, listData, loading, resetForm}">
    <el-form>
      <el-form-item label="元素1:">
        <el-input v-model="formData.input1"/>
      </el-form-item>
      <el-form-item label="元素2:">
        <el-input v-model="formData.input2"/>
      </el-form-item>
      <el-form-item label="元素3:">
        <el-input v-model="formData.input3"/>
      </el-form-item>
      <el-button @click="search">查询</el-button>
      <el-button @click="resetForm">重置</el-button>
    </el-form>
    <el-table :data="listData" v-loading="loading">
    ...
    </el-table>
  </search-list-tool>
</template>
<script>
import SearchListTool from '@/components/SearchListTool.vue'
export default {
  components: {
    SearchListTool
  },
  methods: {
    demoMethod() {
      this.$refs['searchListTool'].refresh()
    },
    requestListMethod(formData, pageNum) {
      // 组装数据
      // 发起请求
      // return [Promise.resolve({listData, totalSize})]
    }
    ...
  }
  ....
}
</script>