530 lines
19 KiB
Markdown
530 lines
19 KiB
Markdown
---
|
||
title: 【SearchPanel组件】下拉联想结果的全局监听click事件技术实现
|
||
description: 写SearchPanel组件时实现下拉联想结果点击外部自动关闭,同时用了全局click事件监听来代替常用的onBlurSuggest逻辑
|
||
date: 2025-02-14T01:31:00+08:00
|
||
slug: 下拉联想
|
||
categories:
|
||
- 实习小记
|
||
- 奇技淫巧
|
||
tags: [
|
||
"Vue",
|
||
"前端",
|
||
"JavaScript"
|
||
]
|
||
lastmod: 2025-02-14T02:58:00+08:00
|
||
links:
|
||
- title: 【SearchPanel组件】详细配置文档
|
||
description: SearchPanel是一个基于Vue的搜索组件,包含各类搜索项和搜索结果表格,支持响应式数据更新
|
||
website: /post/实习小记/searchpanel组件文档/
|
||
---
|
||
|
||
给公司写Vue组件时,需要实现一个下拉联想结果,这个下拉联想结果是根据用户输入内容实时调用API获取的,当用户输入内容时,下拉联想结果会自动弹出。自然地,如果用户点击了其他地方,下拉联想结果应该自动关闭,以保证用户体验。
|
||
|
||
其实一开始我用的是常用的`onBlurSuggest`实现,但是发现有时候点击外部没办法正确隐藏联想结果,分析了一下发现`blur`事件可能在`focus`事件之后触发,所以无法实现点击外部自动关闭。
|
||
|
||
所以我换了种方案,对于输入框和联想结果,我分别加了`ref`来标识两个组件,方便后面检查点击事件时进行定位。输入框:
|
||
```html
|
||
<!-- 联想输入框 -->
|
||
<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"
|
||
/>
|
||
```
|
||
联想结果:
|
||
```html
|
||
<!-- 联想下拉 -->
|
||
<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 元素。
|
||
```js
|
||
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)
|
||
}
|
||
}
|
||
})
|
||
},
|
||
```
|
||
在`mounted`和`beforeDestroy`钩子中分别添加事件监听和移除。注意别忘了在组件销毁前,要移除全局的点击事件监听器,避免内存泄漏。
|
||
```js
|
||
mounted() {
|
||
document.addEventListener('click', this.handleClickOutside)
|
||
// ...
|
||
},
|
||
|
||
beforeDestroy() {
|
||
document.removeEventListener('click', this.handleClickOutside)
|
||
},
|
||
```
|
||
这样一来,可以精确地保证点击外部任意位置时,联想结果都会自动关闭。
|
||
|
||
那么再来说说这样全局监听`click`的方法相比`onBlurSuggest`的优点吧,首先我刚刚说了,通过全局`click`事件监听,可以判断点击事件是否发生在输入框或下拉列表内部,比简单的`blur`事件更灵活,能更好地处理复杂布局或嵌套组件的情况。(事实上,我前面用`blur`的时候就发现,有时候点击外部没办法正确隐藏联想结果)
|
||
|
||
比如:
|
||
```html
|
||
<!-- 当输入框包含子交互元素时 -->
|
||
<div class="input-wrapper">
|
||
<input @blur="...">
|
||
<button @click="clear">×</button> <!-- blur 会误触发 -->
|
||
</div>
|
||
```
|
||
这种情况下全局方案可以识别按钮的点击,但是`blur`方案会误判为离开输入区域。另一个例子(跨组件):
|
||
```html
|
||
<!-- 当下拉框使用 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`失焦,导致下拉框提前关闭,就可能会阻止`click`或`mousedown`事件在下拉项上被正确触发。而全局`click`事件判断可以确保在点击事件发生后再做判断,避免这种“先失焦后点击”带来的问题。
|
||
|
||
| 方案 | 事件触发顺序 | 典型问题场景 |
|
||
|---------------------|----------------------------------|----------------------------------------------------------------------------|
|
||
| **blur 方案** | `mousedown` -> `blur` -> `click` | 点击下拉选项时,输入框先触发 `blur` 导致下拉关闭,无法触发选项的 `click` 事件,一般要设置延时(200ms左右) |
|
||
| **全局点击方案** | 直接捕获 `click` 事件 | 可通过 `mousedown` 提前处理选择逻辑,完美解决时序问题 |
|
||
|
||
另外,页面中有多个联想输入框时,使用全局`click`事件就能统一管理所有输入框的失焦逻辑,避免为每个输入框单独绑定`blur`事件可能带来的冗余代码,或者一些其他的不一致状态。
|
||
|
||
又熬到3点了,看来这辈子不可能早睡了。。。附组件的HTML和JS部分完整代码:(此组件已修改,下面为修改前的代码,非最终版本)
|
||
```html
|
||
<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>
|
||
```
|