【SearchPanel组件】下拉联想结果的全局监听click事件技术实现

写SearchPanel组件时实现下拉联想结果点击外部自动关闭,同时用了全局click事件监听来代替常用的onBlurSuggest逻辑

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

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

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

 1<!-- 联想输入框 -->
 2<input
 3    type="text"
 4    :class="field.inputClass || 'default-input'"
 5    v-model="searchValues[field.key]"
 6    :placeholder="field.placeholder"
 7    @input="onSuggestInput(field)"
 8    @focus="onFocusSuggest(field)"
 9    :ref="'input-' + field.key"
10/>

联想结果:

 1<!-- 联想下拉 -->
 2<div
 3    v-show="showDropdown[field.key]"
 4    class="search-dropdown"
 5    :ref="'dropdown-' + field.key"
 6>
 7    <ul class="search-dropdown-list">
 8        <li
 9        v-for="(item, i2) in suggestResults[field.key] || []"
10        :key="i2"
11        class="search-dropdown-item"
12        @mousedown="onSelectSuggestItem(field, item)"
13        >
14        {{ item }}
15        </li>
16    </ul>
17</div>

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

 1handleClickOutside(event) {
 2    // 遍历所有联想输入框
 3    this.fields.forEach(field => {
 4    if (field.type === 'suggest') {
 5        const dropdownRef = this.$refs['dropdown-' + field.key] // 跟踪联想结果
 6        const inputRef = this.$refs['input-' + field.key] // 跟踪输入框
 7        
 8        // 获取实际 DOM 元素
 9        const dropdownEl = dropdownRef ? dropdownRef[0] : null
10        const inputEl = inputRef ? inputRef[0] : null
11        
12        // 检查点击是否在下拉框或输入框内
13        const clickedInDropdown = dropdownEl && dropdownEl.contains(event.target)
14        const clickedInInput = inputEl && inputEl.contains(event.target)
15
16        if (!clickedInDropdown && !clickedInInput) {
17        // 点击不在下拉框和对应的输入框内,关闭下拉框
18        this.$set(this.showDropdown, field.key, false)
19        }
20    }
21    })
22},

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

1mounted() {
2    document.addEventListener('click', this.handleClickOutside)
3    // ...
4},
5
6beforeDestroy() {
7    document.removeEventListener('click', this.handleClickOutside)
8},

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

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

比如:

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

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

1<!-- 当下拉框使用 Portal 渲染到 body 时 -->
2<template>
3  <input>
4  <Teleport to="body">
5    <div class="dropdown"></div> <!-- blur 事件完全失效 -->
6  </Teleport>
7</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部分完整代码:(此组件已修改,下面为修改前的代码,非最终版本)

  1<template>
  2    <div class="search-panel">
  3      <!-- 搜索条件区域 -->
  4      <div class="search-conditions">
  5        <template v-for="(field, idx) in fields" :key="idx">
  6          <!-- 联想输入框 -->
  7          <div v-if="field.type === 'suggest'" class="search-item">
  8            <div class="search-item-title">{{ field.label }}:</div>
  9            <div class="search-list-wrapper">
 10              <input
 11                type="text"
 12                :class="field.inputClass || 'default-input'"
 13                v-model="searchValues[field.key]"
 14                :placeholder="field.placeholder"
 15                @input="onSuggestInput(field)"
 16                @focus="onFocusSuggest(field)"
 17                :ref="'input-' + field.key"
 18              />
 19              <!-- 联想下拉 -->
 20              <div
 21                v-show="showDropdown[field.key]"
 22                class="search-dropdown"
 23                :ref="'dropdown-' + field.key"
 24              >
 25                <ul class="search-dropdown-list">
 26                  <li
 27                    v-for="(item, i2) in suggestResults[field.key] || []"
 28                    :key="i2"
 29                    class="search-dropdown-item"
 30                    @mousedown="onSelectSuggestItem(field, item)"
 31                  >
 32                    {{ item }}
 33                  </li>
 34                </ul>
 35              </div>
 36            </div>
 37          </div>
 38  
 39          <!-- 日期输入框 -->
 40          <div v-else-if="field.type === 'date'" class="search-item">
 41            <div class="search-item-title">{{ field.label }}:</div>
 42            <input
 43              type="date"
 44              :class="field.inputClass || 'default-input'"
 45              v-model="searchValues[field.key]"
 46              @change="onChangeField(field.key)"
 47            />
 48          </div>
 49  
 50          <!-- 下拉选择框 -->
 51          <div v-else-if="field.type === 'select'" class="search-item">
 52            <div class="search-item-title">{{ field.label }}:</div>
 53            <select
 54              :class="field.inputClass || 'default-input'"
 55              v-model="searchValues[field.key]"
 56              @change="onChangeField(field.key)"
 57            >
 58              <option
 59                v-for="(opt, i3) in field.options || []"
 60                :key="i3"
 61                :value="opt.value"
 62              >
 63                {{ opt.label }}
 64              </option>
 65            </select>
 66          </div>
 67  
 68          <!-- 单选组 (矩形块) -->
 69          <div v-else-if="field.type === 'radioGroup'" class="search-item">
 70            <div class="search-item-title">{{ field.label }}:</div>
 71            <div class="radio-group">
 72              <div
 73                v-for="(opt, i4) in field.options || []"
 74                :key="i4"
 75                class="radio-item"
 76                :class="{ 'radio-item-selected': searchValues[field.key] === opt.value }"
 77                @click="onSelectRadioOption(field.key, opt.value)"
 78              >
 79                {{ opt.label }}
 80              </div>
 81            </div>
 82          </div>
 83  
 84          <!-- 默认文本输入 -->
 85          <div v-else class="search-item">
 86            <div class="search-item-title">{{ field.label }}:</div>
 87            <input
 88              type="text"
 89              :class="field.inputClass || 'default-input'"
 90              v-model="searchValues[field.key]"
 91              :placeholder="field.placeholder"
 92              @input="onChangeField(field.key)"
 93            />
 94          </div>
 95        </template>
 96      </div>
 97  
 98      <!-- 操作按钮区域 -->
 99      <div class="search-actions">
100        <template v-for="(action, idx) in actions" :key="idx">
101          <button
102            class="search-action-btn"
103            :class="action.btnClass"
104            @click="onActionClick(action.key)"
105          >
106            {{ action.label }}
107          </button>
108        </template>
109      </div>
110  
111      <!-- 表格 + 分页 -->
112      <div v-if="showTable" class="search-result">
113        <table class="result-table">
114          <thead>
115            <tr>
116              <th
117                v-for="col in columns"
118                :key="col.key"
119                :style="col.style"
120              >
121                {{ col.label }}
122              </th>
123            </tr>
124          </thead>
125          <tbody>
126            <tr
127              v-for="(row, idx) in tableData"
128              :key="idx"
129              @click="onRowClick(row)"
130            >
131              <td v-for="col in columns" :key="col.key">
132                <component
133                  v-if="col.render"
134                  :is="col.render"
135                  :data="row"
136                  :field="col.key"
137                />
138                <template v-else>
139                  {{ row[col.key] }}
140                </template>
141              </td>
142            </tr>
143          </tbody>
144        </table>
145  
146        <!-- 分页器 -->
147        <div v-if="showPagination && totalPages > 1" class="pagination">
148          <div class="pagination-container">
149            <button
150              class="pagination-btn"
151              :disabled="currentPage <= 1"
152              @click="changePage(currentPage - 1)"
153            >
154              上一页
155            </button>
156            <span class="pagination-text">
157              第 {{ currentPage }} 页 / 共 {{ totalPages }} 页
158            </span>
159            <button
160              class="pagination-btn"
161              :disabled="currentPage >= totalPages"
162              @click="changePage(currentPage + 1)"
163            >
164              下一页
165            </button>
166          </div>
167        </div>
168      </div>
169    </div>
170  </template>
171  
172  <script>
173  import axios from 'axios'
174  
175  export default {
176    name: 'SearchPanel',
177    props: {
178      // 搜索字段配置
179      fields: {
180        type: Array,
181        default: () => []
182      },
183      // 操作按钮
184      actions: {
185        type: Array,
186        default: () => []
187      },
188      // 表格列
189      columns: {
190        type: Array,
191        default: () => []
192      },
193      // 表格数据获取函数
194      // 形参: (searchValues, currentPage, pageSize) => Promise<{ list: Array, total: number }>
195      fetchTableFn: {
196        type: Function,
197        required: true
198      },
199      // 是否显示表格
200      showTable: {
201        type: Boolean,
202        default: true
203      },
204      // 是否显示分页器
205      showPagination: {
206        type: Boolean,
207        default: false
208      },
209      // 是否在字段变化时自动请求
210      autoFetch: {
211        type: Boolean,
212        default: false
213      },
214      // 每页数量
215      pageSize: {
216        type: Number,
217        default: 10
218      }
219    },
220  
221    data() {
222      return {
223        // 内部记录搜索字段的值
224        searchValues: {},
225        // 联想结果
226        suggestResults: {},
227        // 联想下拉是否显示
228        showDropdown: {},
229        // 表格数据
230        tableData: [],
231        // 分页
232        currentPage: 1,
233        totalPages: 1,
234        // 是否正在请求
235        isFetching: false
236      }
237    },
238  
239    watch: {
240      // 监控搜索值变化 => 若autoFetch,则更新表格,并把currentPage重置为1
241      searchValues: {
242        deep: true,
243        handler() {
244          if (this.autoFetch) {
245            this.currentPage = 1
246            this.fetchTableData()
247          }
248        }
249      },
250  
251      // 监控currentPage变化 => 请求新数据
252      currentPage(val) {
253        if (this.autoFetch) {
254          this.fetchTableData()
255        }
256      }
257    },
258  
259    created() {
260      // 初始化 searchValues
261      this.fields.forEach(f => {
262        this.$set(this.searchValues, f.key, f.defaultValue || '')
263      })
264    },
265  
266    mounted() {
267      document.addEventListener('click', this.handleClickOutside)
268      // 开autoFetch了就直接来一次
269      if (this.autoFetch) {
270        this.fetchTableData()
271      }
272    },
273
274    beforeDestroy() {
275        document.removeEventListener('click', this.handleClickOutside)
276    },
277  
278    methods: {
279        handleClickOutside(event) {
280            // 遍历所有联想输入框字段
281            this.fields.forEach(field => {
282            if (field.type === 'suggest') {
283                const dropdownRef = this.$refs['dropdown-' + field.key]
284                const inputRef = this.$refs['input-' + field.key]
285                
286                // 获取实际 DOM 元素
287                const dropdownEl = dropdownRef ? dropdownRef[0] : null
288                const inputEl = inputRef ? inputRef[0] : null
289                
290                // 检查点击有没有在下拉结果或输入框
291                const clickedInDropdown = dropdownEl && dropdownEl.contains(event.target)
292                const clickedInInput = inputEl && inputEl.contains(event.target)
293
294                if (!clickedInDropdown && !clickedInInput) {
295                // 不在,关闭下拉框
296                this.$set(this.showDropdown, field.key, false)
297                }
298            }
299            })
300        },
301      // -------------------------
302      // 通用字段变化逻辑
303      // -------------------------
304      onChangeField(fieldKey) {
305        const val = this.searchValues[fieldKey]
306        this.$emit('change', { key: fieldKey, value: val })
307      },
308  
309      // -------------------------
310      // 单选组
311      // -------------------------
312      onSelectRadioOption(fieldKey, optionValue) {
313        this.searchValues[fieldKey] = optionValue
314        this.onChangeField(fieldKey)
315      },
316  
317      // -------------------------
318      // 联想输入框处理
319      // -------------------------
320      async onSuggestInput(field) {
321        const val = this.searchValues[field.key]
322        if (typeof field.fetchSuggestFn !== 'function') return
323        try {
324            const results = await field.fetchSuggestFn(val)
325            // 注意必须使用this.$set更新响应式数据,不然某些情况检测不到更新!!
326            this.$set(this.suggestResults, field.key, results)
327            this.$set(this.showDropdown, field.key, true)
328        } catch (error) {
329            console.error('获取联想数据失败:', error)
330            // 确保下拉框隐藏
331            this.$set(this.showDropdown, field.key, false)
332        }
333        },
334      onFocusSuggest(field) {
335        // 显示下拉
336        if (
337          this.suggestResults[field.key] &&
338          this.suggestResults[field.key].length > 0
339        ) {
340          this.showDropdown[field.key] = true
341        }
342      },
343    //   onBlurSuggest(field) {
344    //     // 延迟,否则点击下拉时无法触发mousedown
345    //     setTimeout(() => {
346    //       this.showDropdown[field.key] = false
347    //     }, 200)
348    //   },
349      onSelectSuggestItem(field, item) {
350        this.searchValues[field.key] = item
351        this.showDropdown[field.key] = false
352        this.$emit('select', { key: field.key, value: item })
353        // 同时触发字段变化的事件
354        this.$emit('change', { key: field.key, value: item })
355      },
356  
357      // -------------------------
358      // 表格数据请求
359      // -------------------------
360      async fetchTableData() {
361        if (this.isFetching) return
362        this.isFetching = true
363        try {
364          const { list, total } = await this.fetchTableFn(
365            { ...this.searchValues },
366            this.currentPage,
367            this.pageSize
368          )
369          this.tableData = list || []
370          const t = total || 0
371          this.totalPages = Math.ceil(t / this.pageSize)
372        } catch (e) {
373          console.error('fetchTableData error:', e)
374        }
375        this.isFetching = false
376      },
377  
378      changePage(pageNum) {
379        if (pageNum < 1 || pageNum > this.totalPages) return
380        this.currentPage = pageNum
381      },
382  
383      // -------------------------
384      // 操作按钮
385      // -------------------------
386      onActionClick(actionKey) {
387        this.$emit('action', actionKey)
388      },
389  
390      // -------------------------
391      // 表格行点击
392      // -------------------------
393      onRowClick(rowData) {
394        this.$emit('row-click', rowData)
395      }
396    }
397  }
398  </script>