blog/content/post/实习小记/下拉联想.md
2025-02-14 23:16:17 +08:00

19 KiB
Raw Blame History

title description date slug categories tags lastmod links
【SearchPanel组件】下拉联想结果的全局监听click事件技术实现 写SearchPanel组件时实现下拉联想结果点击外部自动关闭同时用了全局click事件监听来代替常用的onBlurSuggest逻辑 2025-02-14T01:31:00+08:00 下拉联想
实习小记
奇技淫巧
Vue
前端
JavaScript
2025-02-14T02:58:00+08:00
title description website
【SearchPanel组件】详细配置文档 SearchPanel是一个基于Vue的搜索组件包含各类搜索项和搜索结果表格支持响应式数据更新 /post/实习小记/searchpanel组件文档/

给公司写Vue组件时需要实现一个下拉联想结果这个下拉联想结果是根据用户输入内容实时调用API获取的当用户输入内容时下拉联想结果会自动弹出。自然地如果用户点击了其他地方下拉联想结果应该自动关闭以保证用户体验。

其实一开始我用的是常用的onBlurSuggest实现,但是发现有时候点击外部没办法正确隐藏联想结果,分析了一下发现blur事件可能在focus事件之后触发,所以无法实现点击外部自动关闭。

所以我换了种方案,对于输入框和联想结果,我分别加了ref来标识两个组件,方便后面检查点击事件时进行定位。输入框:

<!-- 联想输入框 -->
<input
    type="text"
    :class="field.inputClass || 'default-input'"
    v-model="searchValues[field.key]"
    :placeholder="field.placeholder"
    @input="onSuggestInput(field)"
    @focus="onFocusSuggest(field)"
    :ref="'input-' + field.key"
/>

联想结果:

<!-- 联想下拉 -->
<div
    v-show="showDropdown[field.key]"
    class="search-dropdown"
    :ref="'dropdown-' + field.key"
>
    <ul class="search-dropdown-list">
        <li
        v-for="(item, i2) in suggestResults[field.key] || []"
        :key="i2"
        class="search-dropdown-item"
        @mousedown="onSelectSuggestItem(field, item)"
        >
        {{ item }}
        </li>
    </ul>
</div>

两个 ref 属性的用来建立 DOM 元素与组件实例的关联。然后我们在methods中定义了handleClickOutside方法,用来处理点击外部事件。我们遍历所有的联想输入框,检查点击事件是否发生在输入框或下拉联想结果的外部,如果是,就关闭下拉联想结果。在方法中,我们通过this.$refs访问到对应的 DOM 元素。

handleClickOutside(event) {
    // 遍历所有联想输入框
    this.fields.forEach(field => {
    if (field.type === 'suggest') {
        const dropdownRef = this.$refs['dropdown-' + field.key] // 跟踪联想结果
        const inputRef = this.$refs['input-' + field.key] // 跟踪输入框
        
        // 获取实际 DOM 元素
        const dropdownEl = dropdownRef ? dropdownRef[0] : null
        const inputEl = inputRef ? inputRef[0] : null
        
        // 检查点击是否在下拉框或输入框内
        const clickedInDropdown = dropdownEl && dropdownEl.contains(event.target)
        const clickedInInput = inputEl && inputEl.contains(event.target)

        if (!clickedInDropdown && !clickedInInput) {
        // 点击不在下拉框和对应的输入框内,关闭下拉框
        this.$set(this.showDropdown, field.key, false)
        }
    }
    })
},

mountedbeforeDestroy钩子中分别添加事件监听和移除。注意别忘了在组件销毁前,要移除全局的点击事件监听器,避免内存泄漏。

mounted() {
    document.addEventListener('click', this.handleClickOutside)
    // ...
},

beforeDestroy() {
    document.removeEventListener('click', this.handleClickOutside)
},

这样一来,可以精确地保证点击外部任意位置时,联想结果都会自动关闭。

那么再来说说这样全局监听click的方法相比onBlurSuggest的优点吧,首先我刚刚说了,通过全局click事件监听,可以判断点击事件是否发生在输入框或下拉列表内部,比简单的blur事件更灵活,能更好地处理复杂布局或嵌套组件的情况。(事实上,我前面用blur的时候就发现,有时候点击外部没办法正确隐藏联想结果)

比如:

<!-- 当输入框包含子交互元素时 -->
<div class="input-wrapper">
  <input @blur="...">
  <button @click="clear">×</button> <!-- blur 会误触发 -->
</div>

这种情况下全局方案可以识别按钮的点击,但是blur方案会误判为离开输入区域。另一个例子(跨组件):

<!-- 当下拉框使用 Portal 渲染到 body 时 -->
<template>
  <input>
  <Teleport to="body">
    <div class="dropdown"></div> <!-- blur 事件完全失效 -->
  </Teleport>
</template>

使用blur事件时,我们依赖的是输入框与下拉框之间的“焦点”关系来判断用户是否点击在下拉菜单外部。但是通过 Teleport 将下拉菜单渲染到 body 时,输入框和下拉菜单就不再处于同一 DOM 层级内。由于下拉菜单被 Teleport 到了 body它与输入框在 DOM 树中完全分离。点击下拉菜单,输入框会失去焦点,从而触发 blur 事件,而这时我们其实希望下拉菜单保持显示状态。反之,如果不触发 blur,又无法区分点击是否发生在下拉菜单上,所以单纯依赖 blur 很难准确判断点击位置。

换句话说,blur事件本质上只是反映了焦点丢失,而不能判断新获得焦点的元素是否属于下拉菜单的范围。对于 Teleport 渲染的结构,浏览器没办法将下拉菜单与输入框关联起来,从而导致blur事件失效,或者触发不符合预期。而全局的click事件方案则不依赖于组件之间的DOM层级关系。它是直接监听整个文档的点击事件然后通过contains方法判断点击是否发生在输入框或下拉菜单内。无论下拉菜单渲染在哪只要能获取到DOM引用ref),全局方案都能正确判断、控制下拉菜单的显示和隐藏。

另一方面,时序上,用户点击下拉建议时,输入框会先触发blur失焦,导致下拉框提前关闭,就可能会阻止clickmousedown事件在下拉项上被正确触发。而全局click事件判断可以确保在点击事件发生后再做判断,避免这种“先失焦后点击”带来的问题。

方案 事件触发顺序 典型问题场景
blur 方案 mousedown -> blur -> click 点击下拉选项时,输入框先触发 blur 导致下拉关闭,无法触发选项的 click 事件一般要设置延时200ms左右
全局点击方案 直接捕获 click 事件 可通过 mousedown 提前处理选择逻辑,完美解决时序问题

另外,页面中有多个联想输入框时,使用全局click事件就能统一管理所有输入框的失焦逻辑,避免为每个输入框单独绑定blur事件可能带来的冗余代码,或者一些其他的不一致状态。

又熬到3点了看来这辈子不可能早睡了。。。附组件的HTML和JS部分完整代码此组件已修改下面为修改前的代码非最终版本

<template>
    <div class="search-panel">
      <!-- 搜索条件区域 -->
      <div class="search-conditions">
        <template v-for="(field, idx) in fields" :key="idx">
          <!-- 联想输入框 -->
          <div v-if="field.type === 'suggest'" class="search-item">
            <div class="search-item-title">{{ field.label }}</div>
            <div class="search-list-wrapper">
              <input
                type="text"
                :class="field.inputClass || 'default-input'"
                v-model="searchValues[field.key]"
                :placeholder="field.placeholder"
                @input="onSuggestInput(field)"
                @focus="onFocusSuggest(field)"
                :ref="'input-' + field.key"
              />
              <!-- 联想下拉 -->
              <div
                v-show="showDropdown[field.key]"
                class="search-dropdown"
                :ref="'dropdown-' + field.key"
              >
                <ul class="search-dropdown-list">
                  <li
                    v-for="(item, i2) in suggestResults[field.key] || []"
                    :key="i2"
                    class="search-dropdown-item"
                    @mousedown="onSelectSuggestItem(field, item)"
                  >
                    {{ item }}
                  </li>
                </ul>
              </div>
            </div>
          </div>
  
          <!-- 日期输入框 -->
          <div v-else-if="field.type === 'date'" class="search-item">
            <div class="search-item-title">{{ field.label }}</div>
            <input
              type="date"
              :class="field.inputClass || 'default-input'"
              v-model="searchValues[field.key]"
              @change="onChangeField(field.key)"
            />
          </div>
  
          <!-- 下拉选择框 -->
          <div v-else-if="field.type === 'select'" class="search-item">
            <div class="search-item-title">{{ field.label }}</div>
            <select
              :class="field.inputClass || 'default-input'"
              v-model="searchValues[field.key]"
              @change="onChangeField(field.key)"
            >
              <option
                v-for="(opt, i3) in field.options || []"
                :key="i3"
                :value="opt.value"
              >
                {{ opt.label }}
              </option>
            </select>
          </div>
  
          <!-- 单选组 (矩形块) -->
          <div v-else-if="field.type === 'radioGroup'" class="search-item">
            <div class="search-item-title">{{ field.label }}</div>
            <div class="radio-group">
              <div
                v-for="(opt, i4) in field.options || []"
                :key="i4"
                class="radio-item"
                :class="{ 'radio-item-selected': searchValues[field.key] === opt.value }"
                @click="onSelectRadioOption(field.key, opt.value)"
              >
                {{ opt.label }}
              </div>
            </div>
          </div>
  
          <!-- 默认文本输入 -->
          <div v-else class="search-item">
            <div class="search-item-title">{{ field.label }}</div>
            <input
              type="text"
              :class="field.inputClass || 'default-input'"
              v-model="searchValues[field.key]"
              :placeholder="field.placeholder"
              @input="onChangeField(field.key)"
            />
          </div>
        </template>
      </div>
  
      <!-- 操作按钮区域 -->
      <div class="search-actions">
        <template v-for="(action, idx) in actions" :key="idx">
          <button
            class="search-action-btn"
            :class="action.btnClass"
            @click="onActionClick(action.key)"
          >
            {{ action.label }}
          </button>
        </template>
      </div>
  
      <!-- 表格 + 分页 -->
      <div v-if="showTable" class="search-result">
        <table class="result-table">
          <thead>
            <tr>
              <th
                v-for="col in columns"
                :key="col.key"
                :style="col.style"
              >
                {{ col.label }}
              </th>
            </tr>
          </thead>
          <tbody>
            <tr
              v-for="(row, idx) in tableData"
              :key="idx"
              @click="onRowClick(row)"
            >
              <td v-for="col in columns" :key="col.key">
                <component
                  v-if="col.render"
                  :is="col.render"
                  :data="row"
                  :field="col.key"
                />
                <template v-else>
                  {{ row[col.key] }}
                </template>
              </td>
            </tr>
          </tbody>
        </table>
  
        <!-- 分页器 -->
        <div v-if="showPagination && totalPages > 1" class="pagination">
          <div class="pagination-container">
            <button
              class="pagination-btn"
              :disabled="currentPage <= 1"
              @click="changePage(currentPage - 1)"
            >
              上一页
            </button>
            <span class="pagination-text">
              第 {{ currentPage }} 页 / 共 {{ totalPages }} 页
            </span>
            <button
              class="pagination-btn"
              :disabled="currentPage >= totalPages"
              @click="changePage(currentPage + 1)"
            >
              下一页
            </button>
          </div>
        </div>
      </div>
    </div>
  </template>
  
  <script>
  import axios from 'axios'
  
  export default {
    name: 'SearchPanel',
    props: {
      // 搜索字段配置
      fields: {
        type: Array,
        default: () => []
      },
      // 操作按钮
      actions: {
        type: Array,
        default: () => []
      },
      // 表格列
      columns: {
        type: Array,
        default: () => []
      },
      // 表格数据获取函数
      // 形参: (searchValues, currentPage, pageSize) => Promise<{ list: Array, total: number }>
      fetchTableFn: {
        type: Function,
        required: true
      },
      // 是否显示表格
      showTable: {
        type: Boolean,
        default: true
      },
      // 是否显示分页器
      showPagination: {
        type: Boolean,
        default: false
      },
      // 是否在字段变化时自动请求
      autoFetch: {
        type: Boolean,
        default: false
      },
      // 每页数量
      pageSize: {
        type: Number,
        default: 10
      }
    },
  
    data() {
      return {
        // 内部记录搜索字段的值
        searchValues: {},
        // 联想结果
        suggestResults: {},
        // 联想下拉是否显示
        showDropdown: {},
        // 表格数据
        tableData: [],
        // 分页
        currentPage: 1,
        totalPages: 1,
        // 是否正在请求
        isFetching: false
      }
    },
  
    watch: {
      // 监控搜索值变化 => 若autoFetch则更新表格并把currentPage重置为1
      searchValues: {
        deep: true,
        handler() {
          if (this.autoFetch) {
            this.currentPage = 1
            this.fetchTableData()
          }
        }
      },
  
      // 监控currentPage变化 => 请求新数据
      currentPage(val) {
        if (this.autoFetch) {
          this.fetchTableData()
        }
      }
    },
  
    created() {
      // 初始化 searchValues
      this.fields.forEach(f => {
        this.$set(this.searchValues, f.key, f.defaultValue || '')
      })
    },
  
    mounted() {
      document.addEventListener('click', this.handleClickOutside)
      // 开autoFetch了就直接来一次
      if (this.autoFetch) {
        this.fetchTableData()
      }
    },

    beforeDestroy() {
        document.removeEventListener('click', this.handleClickOutside)
    },
  
    methods: {
        handleClickOutside(event) {
            // 遍历所有联想输入框字段
            this.fields.forEach(field => {
            if (field.type === 'suggest') {
                const dropdownRef = this.$refs['dropdown-' + field.key]
                const inputRef = this.$refs['input-' + field.key]
                
                // 获取实际 DOM 元素
                const dropdownEl = dropdownRef ? dropdownRef[0] : null
                const inputEl = inputRef ? inputRef[0] : null
                
                // 检查点击有没有在下拉结果或输入框
                const clickedInDropdown = dropdownEl && dropdownEl.contains(event.target)
                const clickedInInput = inputEl && inputEl.contains(event.target)

                if (!clickedInDropdown && !clickedInInput) {
                // 不在,关闭下拉框
                this.$set(this.showDropdown, field.key, false)
                }
            }
            })
        },
      // -------------------------
      // 通用字段变化逻辑
      // -------------------------
      onChangeField(fieldKey) {
        const val = this.searchValues[fieldKey]
        this.$emit('change', { key: fieldKey, value: val })
      },
  
      // -------------------------
      // 单选组
      // -------------------------
      onSelectRadioOption(fieldKey, optionValue) {
        this.searchValues[fieldKey] = optionValue
        this.onChangeField(fieldKey)
      },
  
      // -------------------------
      // 联想输入框处理
      // -------------------------
      async onSuggestInput(field) {
        const val = this.searchValues[field.key]
        if (typeof field.fetchSuggestFn !== 'function') return
        try {
            const results = await field.fetchSuggestFn(val)
            // 注意必须使用this.$set更新响应式数据不然某些情况检测不到更新
            this.$set(this.suggestResults, field.key, results)
            this.$set(this.showDropdown, field.key, true)
        } catch (error) {
            console.error('获取联想数据失败:', error)
            // 确保下拉框隐藏
            this.$set(this.showDropdown, field.key, false)
        }
        },
      onFocusSuggest(field) {
        // 显示下拉
        if (
          this.suggestResults[field.key] &&
          this.suggestResults[field.key].length > 0
        ) {
          this.showDropdown[field.key] = true
        }
      },
    //   onBlurSuggest(field) {
    //     // 延迟否则点击下拉时无法触发mousedown
    //     setTimeout(() => {
    //       this.showDropdown[field.key] = false
    //     }, 200)
    //   },
      onSelectSuggestItem(field, item) {
        this.searchValues[field.key] = item
        this.showDropdown[field.key] = false
        this.$emit('select', { key: field.key, value: item })
        // 同时触发字段变化的事件
        this.$emit('change', { key: field.key, value: item })
      },
  
      // -------------------------
      // 表格数据请求
      // -------------------------
      async fetchTableData() {
        if (this.isFetching) return
        this.isFetching = true
        try {
          const { list, total } = await this.fetchTableFn(
            { ...this.searchValues },
            this.currentPage,
            this.pageSize
          )
          this.tableData = list || []
          const t = total || 0
          this.totalPages = Math.ceil(t / this.pageSize)
        } catch (e) {
          console.error('fetchTableData error:', e)
        }
        this.isFetching = false
      },
  
      changePage(pageNum) {
        if (pageNum < 1 || pageNum > this.totalPages) return
        this.currentPage = pageNum
      },
  
      // -------------------------
      // 操作按钮
      // -------------------------
      onActionClick(actionKey) {
        this.$emit('action', actionKey)
      },
  
      // -------------------------
      // 表格行点击
      // -------------------------
      onRowClick(rowData) {
        this.$emit('row-click', rowData)
      }
    }
  }
  </script>